archaeopteryx 3.26.0 → 3.28.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.28.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.28.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';
@@ -272,6 +272,9 @@ function (root, d3, forester, phyloXml) {
272
272
  const PHYLOGRAM_MIN_BRANCH_FRACTION = 0.5;
273
273
  const SHORTEN_NAME_MAX_LENGTH = 18;
274
274
  const PANEL_STYLE_ID = 'aptx-panel-styles';
275
+ // The tree's own <svg>: named so that the stylesheet can say what a host
276
+ // page's rules for a bare "svg" would otherwise decide (hostProofCss).
277
+ const TREE_SVG_CLASS = 'aptx-tree';
275
278
  // How wide a legend row is treated as, for grabbing it with the mouse.
276
279
  const PANEL_WIDTH = 214; // fixed control-panel width; shared by the .aptx-panel CSS and leftPanelClearance() so the two can't drift
277
280
  const PANEL_WIDTH_COMPACT = 196; // the same, at panelDensity 'compact'; panelWidth() picks between them
@@ -303,6 +306,52 @@ function (root, d3, forester, phyloXml) {
303
306
  const MESSAGE = 'ArchaeopteryxJS: ';
304
307
  const ERROR = 'ArchaeopteryxJS: ERROR: ';
305
308
 
309
+ // What this launch had to say, kept as well as printed. The console is
310
+ // where a warning goes and where nobody embedding the viewer looks: a
311
+ // site passed a retired key for a month and a forced visualization for
312
+ // longer (BV-BRC, 2026-10-08), each announced on every page load. The
313
+ // Diagnostics report (archaeopteryx.doctor, further down) reads this
314
+ // list; 'problem' entries are somebody's mistake and light the dot on
315
+ // the program name, 'note' entries are only stated. The same line twice
316
+ // is one entry with a count.
317
+ //
318
+ // The list belongs to the tree on view. It starts afresh whenever the
319
+ // launch proper (launchInto) begins for a tree -- a new launch, or
320
+ // showTree() moving to another tree of the file, whose config warnings
321
+ // are then said again and whose per-tree ones are its own. A launch that
322
+ // is REJECTED while an earlier viewer is still on the page gives that
323
+ // viewer its list back and adds one line saying what failed (the shell
324
+ // round launch()): the page still shows the earlier tree, and the report
325
+ // describes the page.
326
+ const DIAGNOSTICS_MAX = 40;
327
+ let _diagnostics = [];
328
+ // What is on view, noted at the moment a launch takes the page over (it
329
+ // clears the container): the report's "launch" section and the dot read
330
+ // this, never the module's working state, which a launch writes to
331
+ // before the checks that can still reject it.
332
+ let _onView = null;
333
+ let _tookOver = false;
334
+ let _recordedErrors = (typeof WeakSet === 'function') ? new WeakSet() : null;
335
+ function recordDiagnostic(level, line) {
336
+ let text = String(line).replace(/^ArchaeopteryxJS: (WARNING: |ERROR: )?/, '');
337
+ let seen = _diagnostics.find(function (d) {
338
+ return d.level === level && d.text === text;
339
+ });
340
+ if (seen) {
341
+ seen.count += 1;
342
+ } else if (_diagnostics.length < DIAGNOSTICS_MAX) {
343
+ _diagnostics.push({level: level, text: text, count: 1});
344
+ }
345
+ if (level === 'problem') {
346
+ syncDoctorDot();
347
+ }
348
+ }
349
+ // Print as before (same console method, same line) and keep it.
350
+ function say(level, method, line) {
351
+ console[method](line);
352
+ recordDiagnostic(level, line);
353
+ }
354
+
306
355
  // Call-time dependency guards (the UMD header explains why they are not
307
356
  // load-time): each failure names exactly what is missing.
308
357
  function requireD3() {
@@ -408,6 +457,7 @@ function (root, d3, forester, phyloXml) {
408
457
  // archaeopteryx-anime.svg), inlined so the library stays a single file.
409
458
  // Gradient ids are prefixed: they were generic enough to collide with an
410
459
  // embedding page's own defs.
460
+ // (drawn with attributes, as any svg file is; paintInline moves its paint into styles where it is used)
411
461
  const ARCHAEOPTERYX_LOGO_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Archaeopteryx"> <defs> <linearGradient id="aptxlogo-wg" x1="0" y1="1" x2="1" y2="0"> <stop offset="0" stop-color="#4f46e5"/><stop offset="0.55" stop-color="#38bdf8"/><stop offset="1" stop-color="#a7f3d0"/> </linearGradient> <linearGradient id="aptxlogo-wg2" x1="0" y1="1" x2="1" y2="0"> <stop offset="0" stop-color="#3730a3"/><stop offset="1" stop-color="#4f79e0"/> </linearGradient> <linearGradient id="aptxlogo-tg" x1="0" y1="1" x2="1" y2="0"> <stop offset="0" stop-color="#f43f5e"/><stop offset="0.5" stop-color="#fb923c"/><stop offset="1" stop-color="#fde047"/> </linearGradient> <linearGradient id="aptxlogo-bg" x1="0" y1="0" x2="0" y2="1"> <stop offset="0" stop-color="#fff7e0"/><stop offset="1" stop-color="#fcd34d"/> </linearGradient> </defs> <g transform="translate(28.9,18.0) scale(0.9091)"> <g stroke="#25304f" stroke-width="6" stroke-linejoin="round" stroke-linecap="round"> <path d="M 78.0,152.0 C 49.3,150.6 12.5,173.3 -4.4,194.3 C 25.1,192.0 66.1,175.5 78.0,152.0 Z" fill="url(#aptxlogo-tg)"/> <path d="M 78.0,152.0 C 48.1,143.8 3.5,158.8 -19.7,176.6 C 12.1,181.2 59.5,173.7 78.0,152.0 Z" fill="url(#aptxlogo-tg)"/> <path d="M 78.0,152.0 C 51.8,138.2 6.9,142.5 -18.7,154.0 C 10.0,164.8 56.0,167.9 78.0,152.0 Z" fill="url(#aptxlogo-tg)"/> <path d="M 78.0,152.0 C 58.9,135.3 20.6,130.3 -2.8,135.1 C 18.9,149.7 56.6,161.2 78.0,152.0 Z" fill="url(#aptxlogo-tg)"/> <path d="M 118.0,126.0 C 110.4,106.2 84.4,93.0 65.5,92.1 C 75.9,110.5 99.0,129.6 118.0,126.0 Z" fill="url(#aptxlogo-wg2)"/> <path d="M 118.0,126.0 C 115.6,103.3 92.6,79.7 73.5,71.3 C 79.1,93.8 97.5,122.0 118.0,126.0 Z" fill="url(#aptxlogo-wg2)"/> <path d="M 118.0,126.0 C 123.0,106.1 110.4,79.8 96.5,67.4 C 94.4,87.9 101.6,117.0 118.0,126.0 Z" fill="url(#aptxlogo-wg2)"/> <path d="M 106,172 L 101,202 M 132,172 L 129,202" fill="none" stroke-width="9"/> <path d="M 101,202 L 88,209 M 101,202 L 112,210 M 101,202 L 99,213 M 129,202 L 116,209 M 129,202 L 140,210 M 129,202 L 128,213" fill="none" stroke-width="6"/> <path d="M 96,176 C 74,168 66,146 76,128 C 86,110 110,102 132,106 C 152,110 164,126 162,146 C 160,166 142,180 120,180 C 112,180 103,179 96,176 Z" fill="url(#aptxlogo-bg)"/> <path d="M 100,174 C 86,166 82,150 90,138 C 96,146 108,152 122,152 C 136,152 148,148 156,140 C 160,156 150,172 128,177 C 118,179 108,178 100,174 Z" fill="#fff6dd" stroke="none"/> <path d="M 96,176 C 74,168 66,146 76,128 C 86,110 110,102 132,106 C 152,110 164,126 162,146 C 160,166 142,180 120,180 C 112,180 103,179 96,176 Z" fill="none"/> <path d="M 150.0,60.0 C 159.5,51.2 158.7,35.2 152.9,25.9 C 145.3,35.7 141.5,51.8 150.0,60.0 Z" fill="url(#aptxlogo-wg)"/> <path d="M 164.0,56.0 C 174.4,51.0 177.8,37.3 175.2,28.0 C 166.2,34.2 158.9,46.9 164.0,56.0 Z" fill="url(#aptxlogo-wg)"/> <path d="M 136.0,66.0 C 141.8,56.5 137.0,44.2 129.6,38.5 C 125.7,48.3 126.7,61.9 136.0,66.0 Z" fill="url(#aptxlogo-wg)"/> <path d="M 158,58 C 186,54 208,72 208,96 C 208,120 188,136 164,134 C 140,132 126,114 128,92 C 130,72 142,60 158,58 Z" fill="url(#aptxlogo-bg)"/> <path d="M 203,82 C 216,84 230,92 238,100 C 230,108 216,113 202,112 C 206,104 206,90 203,82 Z" fill="#fb923c"/> <path d="M 204,101 C 214,102 224,103 234,101" fill="none" stroke-width="4"/> <path d="M 128.0,116.0 C 113.1,91.9 75.3,80.3 50.0,83.2 C 68.3,104.8 103.7,124.6 128.0,116.0 Z" fill="url(#aptxlogo-wg)"/> <path d="M 128.0,116.0 C 118.0,85.8 79.7,60.2 51.1,54.1 C 65.4,83.1 98.9,116.3 128.0,116.0 Z" fill="url(#aptxlogo-wg)"/> <path d="M 128.0,116.0 C 127.0,83.7 97.3,47.3 71.5,32.7 C 76.9,65.0 99.8,107.4 128.0,116.0 Z" fill="url(#aptxlogo-wg)"/> <path d="M 128.0,116.0 C 136.1,88.7 121.1,50.1 103.3,30.9 C 99.0,59.5 106.6,101.2 128.0,116.0 Z" fill="url(#aptxlogo-wg)"/> </g> <g> <ellipse cx="172" cy="94" rx="15" ry="17" fill="#25304f"/> <ellipse cx="172" cy="97" rx="10" ry="11" fill="#38bdf8"/> <circle cx="167" cy="88" r="6" fill="#ffffff"/> <circle cx="178" cy="103" r="3" fill="#ffffff" opacity="0.9"/> <ellipse cx="146" cy="112" rx="9" ry="5" fill="#fb7185" opacity="0.75"/> <path d="M 52,53 C 53.62,59.75 54.25,60.38 61,62 C 54.25,63.62 53.62,64.25 52,71 C 50.38,64.25 49.75,63.62 43,62 C 49.75,60.38 50.38,59.75 52,53 Z" fill="#fde68a"/> <path d="M 222,161 C 223.26,166.25 223.75,166.74 229,168 C 223.75,169.26 223.26,169.75 222,175 C 220.74,169.75 220.25,169.26 215,168 C 220.25,166.74 220.74,166.25 222,161 Z" fill="#fde68a"/> <path d="M 86,208 C 87.08,212.5 87.5,212.92 92,214 C 87.5,215.08 87.08,215.5 86,220 C 84.92,215.5 84.5,215.08 80,214 C 84.5,212.92 84.92,212.5 86,208 Z" fill="#fde68a"/> </g> </g> </svg>';
412
462
  const NODE_DATA = 'node_data_dialog';
413
463
  const COLOR_PICKER = 'color_picker_dialog';
@@ -440,6 +490,7 @@ function (root, d3, forester, phyloXml) {
440
490
  const CLOCK_PLOT_BUTTON = 'clock_plot_b';
441
491
  const CLOCK_PLOT_DIALOG = 'aptx_clock_plot';
442
492
  const ABOUT_DIALOG = 'aptx_about';
493
+ const DOCTOR_DIALOG = 'aptx_doctor';
443
494
  const TREE_PROPERTIES_DIALOG = 'aptx_tree_props';
444
495
  const PROG_NAME = 'progname';
445
496
  const PROGNAMELINK = 'prognamelink';
@@ -1491,9 +1542,9 @@ function (root, d3, forester, phyloXml) {
1491
1542
  let fill = hoverGlowColor(d);
1492
1543
  HOVER_GLOW_RADII.forEach(function (radius, i) {
1493
1544
  g.append('circle')
1494
- .attr('fill-opacity', HOVER_GLOW_ALPHAS[i])
1495
- .attr('stroke', 'none')
1496
- .attr('fill', fill)
1545
+ .style('fill-opacity', HOVER_GLOW_ALPHAS[i])
1546
+ .style('stroke', 'none')
1547
+ .style('fill', fill)
1497
1548
  .attr('r', (dia * radius) / 2);
1498
1549
  });
1499
1550
  }
@@ -1938,7 +1989,7 @@ function (root, d3, forester, phyloXml) {
1938
1989
  || (c.label && String(c.label).toLowerCase() === want);
1939
1990
  }) || null;
1940
1991
  if (!requested) {
1941
- console.warn(WARNING + ': initialVisualization "'
1992
+ say('problem', 'warn', WARNING + ': initialVisualization "'
1942
1993
  + _settings.initialVisualization
1943
1994
  + '" names no visualization of this tree; using the automatic choice');
1944
1995
  }
@@ -1968,7 +2019,7 @@ function (root, d3, forester, phyloXml) {
1968
2019
  ? requested.values.length + ' distinct values on this tree, more than the '
1969
2020
  + LEGEND_MAX_ROWS + ' the legend lists'
1970
2021
  : 'a value on ' + requested.coverage + ' of the tree\'s ' + requested.total + ' tips';
1971
- console.warn(WARNING + ': initialVisualization "' + _settings.initialVisualization
2022
+ say('problem', 'warn', WARNING + ': initialVisualization "' + _settings.initialVisualization
1972
2023
  + '" names a field with ' + why + '; left to itself the viewer would '
1973
2024
  + (auto ? 'open with "' + auto.label + '" (' + auto.values.length + ' values)'
1974
2025
  : 'open uncoloured'));
@@ -2711,7 +2762,7 @@ function (root, d3, forester, phyloXml) {
2711
2762
  chips.forEach(function (chip) {
2712
2763
  let t = g.append('text')
2713
2764
  .attr('x', chipRight).attr('y', baseline)
2714
- .attr('text-anchor', 'end')
2765
+ .style('text-anchor', 'end')
2715
2766
  .style('font', rowFont)
2716
2767
  .style('fill', ink)
2717
2768
  .style('fill-opacity', 0.75)
@@ -2739,10 +2790,10 @@ function (root, d3, forester, phyloXml) {
2739
2790
  let frac = max > min ? (mean - min) / (max - min) : 0.5;
2740
2791
  let gradId = 'aptx_legend_ramp_' + id;
2741
2792
  let grad = g.append('defs').append('linearGradient').attr('id', gradId);
2742
- grad.append('stop').attr('offset', '0%').attr('stop-color', VIS_COLOR_RAMP[0]);
2793
+ grad.append('stop').attr('offset', '0%').style('stop-color', VIS_COLOR_RAMP[0]);
2743
2794
  grad.append('stop').attr('offset', (100 * Math.max(0.01, Math.min(0.99, frac))) + '%')
2744
- .attr('stop-color', VIS_COLOR_RAMP[1]);
2745
- grad.append('stop').attr('offset', '100%').attr('stop-color', VIS_COLOR_RAMP[2]);
2795
+ .style('stop-color', VIS_COLOR_RAMP[1]);
2796
+ grad.append('stop').attr('offset', '100%').style('stop-color', VIS_COLOR_RAMP[2]);
2746
2797
  let barY = baseline + 6;
2747
2798
  let barW = width - 2 * PAD;
2748
2799
  g.append('rect')
@@ -2758,7 +2809,7 @@ function (root, d3, forester, phyloXml) {
2758
2809
  .text(legendNumberLabel(min));
2759
2810
  g.append('text')
2760
2811
  .attr('x', x + PAD + barW).attr('y', lblY)
2761
- .attr('text-anchor', 'end')
2812
+ .style('text-anchor', 'end')
2762
2813
  .style('font', rowFont).style('fill', ink)
2763
2814
  .text(legendNumberLabel(max));
2764
2815
  baseline = lblY;
@@ -2823,7 +2874,7 @@ function (root, d3, forester, phyloXml) {
2823
2874
  .text(r.text);
2824
2875
  g.append('text')
2825
2876
  .attr('x', x + width - PAD).attr('y', baseline)
2826
- .attr('text-anchor', 'end')
2877
+ .style('text-anchor', 'end')
2827
2878
  .style('font', rowFont)
2828
2879
  .style('fill', ink)
2829
2880
  .style('fill-opacity', 0.55)
@@ -3333,7 +3384,7 @@ function (root, d3, forester, phyloXml) {
3333
3384
  .style('paint-order', 'stroke')
3334
3385
  // anchor, dy and x all come from nodeLabelPlacement, which the
3335
3386
  // occupancy box is built from too
3336
- .attr('text-anchor', function (d) {
3387
+ .style('text-anchor', function (d) {
3337
3388
  return nodeLabelPlacement(d).anchor;
3338
3389
  })
3339
3390
  .attr('transform', function (d) {
@@ -3371,7 +3422,7 @@ function (root, d3, forester, phyloXml) {
3371
3422
  node.select('text.bllabel')
3372
3423
  .style('font-size', _state.branchDataFontSize + 'px')
3373
3424
  .style('fill', _state.branchColorDefault)
3374
- .attr('text-anchor', function () {
3425
+ .style('text-anchor', function () {
3375
3426
  return radialDisplay() ? 'middle' : null;
3376
3427
  })
3377
3428
  .attr('transform', function (d) {
@@ -3538,9 +3589,9 @@ function (root, d3, forester, phyloXml) {
3538
3589
  if (tipGuides || collapsedGuides.length > 0) {
3539
3590
  let ext = _svgGroup.insert('g', 'g').attr('class', 'aptx-align-ext');
3540
3591
  let guideStyle = function (sel) {
3541
- return sel.attr('fill', 'none')
3542
- .attr('stroke-width', 1)
3543
- .attr('stroke', _state.branchColorDefault)
3592
+ return sel.style('fill', 'none')
3593
+ .style('stroke-width', 1)
3594
+ .style('stroke', _state.branchColorDefault)
3544
3595
  .style('stroke-opacity', 0.25);
3545
3596
  };
3546
3597
  if (tipGuides) {
@@ -3585,10 +3636,10 @@ function (root, d3, forester, phyloXml) {
3585
3636
  conn.selectAll('line')
3586
3637
  .data(connected)
3587
3638
  .enter().append('line')
3588
- .attr('stroke', _state.branchColorDefault)
3639
+ .style('stroke', _state.branchColorDefault)
3589
3640
  // width/opacity match the rectangular aligned extensions; the
3590
3641
  // dash alone marks these as connectors (0.5px at 0.3 vanished)
3591
- .attr('stroke-width', 1)
3642
+ .style('stroke-width', 1)
3592
3643
  .style('stroke-opacity', 0.4)
3593
3644
  .style('stroke-dasharray', '2,3')
3594
3645
  .attr('x1', function (d) { return radialXY(d.x, innerY(d))[0]; })
@@ -4682,7 +4733,7 @@ function (root, d3, forester, phyloXml) {
4682
4733
  sync(g, 'text.extlabel', d._extLabelText !== '', function (sel) {
4683
4734
  sel.append('text')
4684
4735
  .attr('class', 'extlabel')
4685
- .attr('text-anchor', d.children ? 'end' : 'start')
4736
+ .style('text-anchor', d.children ? 'end' : 'start')
4686
4737
  .style('font-family', font)
4687
4738
  .style('fill-opacity', 0.5);
4688
4739
  });
@@ -4698,7 +4749,7 @@ function (root, d3, forester, phyloXml) {
4698
4749
  sync(g, 'text.conflabel', d._confText !== '', function (sel) {
4699
4750
  sel.append('text')
4700
4751
  .attr('class', 'conflabel')
4701
- .attr('text-anchor', 'middle')
4752
+ .style('text-anchor', 'middle')
4702
4753
  .style('font-family', font);
4703
4754
  });
4704
4755
  }
@@ -4706,7 +4757,7 @@ function (root, d3, forester, phyloXml) {
4706
4757
  sync(g, 'text.brancheventlabel', d._eventText !== '', function (sel) {
4707
4758
  sel.append('text')
4708
4759
  .attr('class', 'brancheventlabel')
4709
- .attr('text-anchor', 'middle')
4760
+ .style('text-anchor', 'middle')
4710
4761
  .style('font-family', font);
4711
4762
  });
4712
4763
  }
@@ -5346,9 +5397,16 @@ function (root, d3, forester, phyloXml) {
5346
5397
  let els = sizeLayer(_geomLayers.branches, 'path', branchList.length);
5347
5398
  branchList.forEach(function (b, k) {
5348
5399
  b.el = els[k];
5349
- b.el.setAttribute('fill', 'none');
5350
- b.el.setAttribute('stroke', b.color);
5351
- b.el.setAttribute('stroke-width', b.width);
5400
+ // These paths are kept from one drawing to the next, and a style
5401
+ // is not an attribute: a value that is no colour or no width (a
5402
+ // file's <width>-1</width>) is IGNORED, and the path would keep
5403
+ // what another batch gave it the last time. So the old is taken
5404
+ // off first, and such a batch is drawn as the svg has it.
5405
+ b.el.style.setProperty('fill', 'none');
5406
+ b.el.style.removeProperty('stroke');
5407
+ b.el.style.setProperty('stroke', b.color);
5408
+ b.el.style.removeProperty('stroke-width');
5409
+ b.el.style.setProperty('stroke-width', b.width);
5352
5410
  });
5353
5411
  // `last`: batches painted after the sorted ones, whatever their count
5354
5412
  let paintLayer = function (batches, layer, last) {
@@ -5825,14 +5883,14 @@ function (root, d3, forester, phyloXml) {
5825
5883
  return;
5826
5884
  }
5827
5885
  if (dep.to) {
5828
- console.warn(WARNING + ': "' + k + '" was replaced by "' + dep.to
5886
+ say('problem', 'warn', WARNING + ': "' + k + '" was replaced by "' + dep.to
5829
5887
  + '": ' + dep.note);
5830
5888
  // An explicit new key always wins over the old alias.
5831
5889
  if (given[dep.to] === undefined) {
5832
5890
  given[dep.to] = dep.map(given[k]);
5833
5891
  }
5834
5892
  } else {
5835
- console.warn(WARNING + ': "' + k + '" is retired and has no effect: ' + dep.note);
5893
+ say('problem', 'warn', WARNING + ': "' + k + '" is retired and has no effect: ' + dep.note);
5836
5894
  }
5837
5895
  delete given[k];
5838
5896
  });
@@ -6078,7 +6136,7 @@ function (root, d3, forester, phyloXml) {
6078
6136
  // malformed domains are skipped, never fatal -- said once, here
6079
6137
  let ignored = forester.domainArchitectureStats(_treeData).ignored;
6080
6138
  if (ignored > 0) {
6081
- console.warn(MESSAGE + ignored + ' protein domain' + (ignored === 1 ? '' : 's')
6139
+ say('note', 'warn', MESSAGE + ignored + ' protein domain' + (ignored === 1 ? '' : 's')
6082
6140
  + ' with a missing or impossible from / to / E-value ignored');
6083
6141
  }
6084
6142
  }
@@ -6111,11 +6169,11 @@ function (root, d3, forester, phyloXml) {
6111
6169
  // are left out, never fatal -- said once, here
6112
6170
  let gs = forester.geneRegionStats(_treeData);
6113
6171
  if (gs.ignored > 0) {
6114
- console.warn(MESSAGE + gs.ignored + ' sequence location' + (gs.ignored === 1 ? '' : 's')
6172
+ say('note', 'warn', MESSAGE + gs.ignored + ' sequence location' + (gs.ignored === 1 ? '' : 's')
6115
6173
  + ' not in the form contig:start-end(+) ignored');
6116
6174
  }
6117
6175
  if (gs.otherContigs > 0) {
6118
- console.warn(MESSAGE + gs.otherContigs + ' gene' + (gs.otherContigs === 1 ? '' : 's')
6176
+ say('note', 'warn', MESSAGE + gs.otherContigs + ' gene' + (gs.otherContigs === 1 ? '' : 's')
6119
6177
  + ' on a genome\'s other contigs not drawn: the gene track shows one contig per genome');
6120
6178
  }
6121
6179
  }
@@ -6347,11 +6405,11 @@ function (root, d3, forester, phyloXml) {
6347
6405
  _settings.enableDynamicSizing = true;
6348
6406
  }
6349
6407
  if (_settings.displayWidth && _settings.enableDynamicSizing === true) {
6350
- console.log(WARNING + ': dynamic sizing is turned on, will ignore displayWidth setting');
6408
+ say('note', 'log', WARNING + ': dynamic sizing is turned on, will ignore displayWidth setting');
6351
6409
  _settings.displayWidth = 0;
6352
6410
  }
6353
6411
  if (_settings.displayHeight && _settings.enableDynamicSizing === true) {
6354
- console.log(WARNING + ': dynamic sizing is turned on, will ignore displayHeight setting');
6412
+ say('note', 'log', WARNING + ': dynamic sizing is turned on, will ignore displayHeight setting');
6355
6413
  _settings.displayHeight = 0;
6356
6414
  }
6357
6415
  if ((!_settings.displayWidth) && (!_settings.enableDynamicSizing)) {
@@ -6640,7 +6698,40 @@ function (root, d3, forester, phyloXml) {
6640
6698
 
6641
6699
  // launch(container, tree, config) -> a viewer handle {getSelectedNodes,
6642
6700
  // destroy}. Exactly three arguments; config is THE one config object.
6643
- archaeopteryx.launch = function (container, phylo, config) {
6701
+ // launch() proper is launchChecked below. This shell keeps what a
6702
+ // rejected launch said: the thrown message reaches whoever called, which
6703
+ // on an embedding site is often a catch block nobody reads, and
6704
+ // archaeopteryx.doctor() can still tell. (launchArchaeopteryx has its own
6705
+ // failures -- a file that does not parse -- and reports them through the
6706
+ // same function; an error is recorded once, whoever catches it first.)
6707
+ function recordLaunchFailure(e) {
6708
+ if (_recordedErrors && e && typeof e === 'object') {
6709
+ if (_recordedErrors.has(e)) {
6710
+ return;
6711
+ }
6712
+ _recordedErrors.add(e);
6713
+ }
6714
+ recordDiagnostic('problem', 'launch failed: '
6715
+ + String(e && e.message ? e.message : e).replace(/^ArchaeopteryxJS: ERROR: /, ''));
6716
+ }
6717
+ archaeopteryx.launch = function () {
6718
+ let before = _diagnostics;
6719
+ let wasOnView = _onView;
6720
+ _tookOver = false;
6721
+ try {
6722
+ return launchChecked.apply(null, arguments);
6723
+ } catch (e) {
6724
+ if (wasOnView && !_tookOver) {
6725
+ // rejected before it touched the page: the earlier viewer is
6726
+ // still there, and so is everything said about it
6727
+ _diagnostics = before;
6728
+ }
6729
+ recordLaunchFailure(e);
6730
+ throw e;
6731
+ }
6732
+ };
6733
+
6734
+ function launchChecked(container, phylo, config) {
6644
6735
 
6645
6736
  // Bad input is the caller's bug, so it is thrown at the caller. It used
6646
6737
  // to pop a browser alert and return, which blocks the whole tab and
@@ -6715,12 +6806,14 @@ function (root, d3, forester, phyloXml) {
6715
6806
  let view = config && config.view;
6716
6807
  let start = (view && Number.isInteger(view.tree) && view.tree > 0 && view.tree < trees.length) ? view.tree : 0;
6717
6808
  return launchInto(container, trees, start, config);
6718
- };
6809
+ }
6719
6810
 
6720
6811
  // The launch proper, for one tree of the list: launch() validates and
6721
6812
  // starts at the first, showTree() re-enters here for another.
6722
6813
  function launchInto(container, trees, index, config) {
6723
6814
  let phylo = trees[index];
6815
+ // what is said from here on is said about THIS tree (see _diagnostics)
6816
+ _diagnostics = [];
6724
6817
  let cfg = readConfig(config);
6725
6818
  requireForester();
6726
6819
  requireD3();
@@ -6767,6 +6860,11 @@ function (root, d3, forester, phyloXml) {
6767
6860
  checkLayoutValue(cfg.state ? cfg.state.layout : undefined);
6768
6861
  initializeSettings(cfg.settings);
6769
6862
 
6863
+ // From here the page is this launch's: nothing below is expected to
6864
+ // throw, and what a previous one drew goes now.
6865
+ _tookOver = true;
6866
+ _onView = {config: config, container: containerEl, tips: _basicTreeProperties.externalNodesCount,
6867
+ tree: index + 1, trees: trees.length};
6770
6868
  // whatever a previous launch left in this container (or in the
6771
6869
  // container before it) goes now, so the old tree does not sit under
6772
6870
  // the card while the new one is prepared
@@ -6805,8 +6903,15 @@ function (root, d3, forester, phyloXml) {
6805
6903
  createGui();
6806
6904
 
6807
6905
  _baseSvg = d3.select(containerEl).append('svg')
6808
- .attr('width', _displayWidth)
6809
- .attr('height', _displayHeight)
6906
+ .attr('class', TREE_SVG_CLASS)
6907
+ .call(sizeTreeSvg, _displayWidth, _displayHeight)
6908
+ // The tree's font, for the texts that state only a size (the
6909
+ // alignment strip's ruler and caption, the heat map strip's
6910
+ // numbers and caption): they were drawn in the PAGE's font,
6911
+ // in boxes measured for this one, and in an exported file in
6912
+ // whatever the program that opened it thought best. Inline,
6913
+ // so that the file has it too.
6914
+ .style('font-family', FONT_DEFAULTS)
6810
6915
  // an inline svg sits on a text baseline, and the room left
6811
6916
  // under it for descenders makes a container sized to the
6812
6917
  // svg a few pixels taller than the svg itself -- enough to
@@ -6829,8 +6934,7 @@ function (root, d3, forester, phyloXml) {
6829
6934
  }
6830
6935
  _displayWidth = size.w;
6831
6936
  _displayHeight = size.h;
6832
- _baseSvg.attr('width', size.w);
6833
- _baseSvg.attr('height', size.h);
6937
+ sizeTreeSvg(_baseSvg, size.w, size.h);
6834
6938
  rebuildOverview();
6835
6939
  if ((_settings.zoomToFitUponWindowResize === true) && (_zoomed_x_or_y === false) && (Math.abs(currentZoomScale() - 1.0) < 0.001)) {
6836
6940
  zoomToFit();
@@ -6977,6 +7081,11 @@ function (root, d3, forester, phyloXml) {
6977
7081
  cfg.view = view || viewForTree(_trees[index]);
6978
7082
  let handle = launchInto(_container, _trees, index, cfg);
6979
7083
  _launchConfig = launchConfig;
7084
+ // the report shows the config the SITE passed, not this copy with
7085
+ // the tree's own view added
7086
+ if (_onView) {
7087
+ _onView.config = launchConfig;
7088
+ }
6980
7089
  return handle;
6981
7090
  }
6982
7091
 
@@ -7897,7 +8006,7 @@ function (root, d3, forester, phyloXml) {
7897
8006
  let x1 = Math.round(originX + (endI * cw));
7898
8007
  g.append('rect').attr('x', x0).attr('y', cy)
7899
8008
  .attr('width', Math.max(1, x1 - x0)).attr('height', rh)
7900
- .attr('fill', 'rgb(' + runRgb.join(',') + ')');
8009
+ .style('fill', 'rgb(' + runRgb.join(',') + ')');
7901
8010
  runStart = -1;
7902
8011
  runRgb = null;
7903
8012
  };
@@ -7912,7 +8021,7 @@ function (root, d3, forester, phyloXml) {
7912
8021
  g.append('line')
7913
8022
  .attr('x1', Math.round(originX + (gapStart * cw))).attr('y1', mid)
7914
8023
  .attr('x2', Math.round(originX + (endI * cw))).attr('y2', mid)
7915
- .attr('stroke', ink).attr('stroke-opacity', 0.35).attr('stroke-width', 1);
8024
+ .style('stroke', ink).style('stroke-opacity', 0.35).style('stroke-width', 1);
7916
8025
  gapStart = -1;
7917
8026
  };
7918
8027
  for (let i = 0; i < visible; ++i) {
@@ -7936,9 +8045,9 @@ function (root, d3, forester, phyloXml) {
7936
8045
  let x1 = Math.round(originX + ((i + 1) * cw));
7937
8046
  g.append('rect').attr('x', x0).attr('y', cy)
7938
8047
  .attr('width', Math.max(1, x1 - x0)).attr('height', rh)
7939
- .attr('fill', 'rgb(' + rgb.join(',') + ')');
8048
+ .style('fill', 'rgb(' + rgb.join(',') + ')');
7940
8049
  g.append('text').attr('x', x0 + ((x1 - x0) / 2)).attr('y', cy + (rh / 2))
7941
- .attr('text-anchor', 'middle').attr('dy', '0.35em')
8050
+ .style('text-anchor', 'middle').attr('dy', '0.35em')
7942
8051
  .style('font-family', 'monospace').style('font-size', fontPx + 'px')
7943
8052
  .style('fill', forester.msaLetterInk(rgb))
7944
8053
  .text(ch.toUpperCase());
@@ -7962,13 +8071,13 @@ function (root, d3, forester, phyloXml) {
7962
8071
  if (offset <= 0) {
7963
8072
  g.append('line').attr('x1', Math.round(originX)).attr('x2', Math.round(originX))
7964
8073
  .attr('y1', rowsTop).attr('y2', rowsBottom)
7965
- .attr('stroke', ink).attr('stroke-opacity', 0.7).attr('stroke-width', 1);
8074
+ .style('stroke', ink).style('stroke-opacity', 0.7).style('stroke-width', 1);
7966
8075
  }
7967
8076
  if ((offset + visible) >= total) {
7968
8077
  let xe = Math.round(originX + (visible * cw));
7969
8078
  g.append('line').attr('x1', xe).attr('x2', xe)
7970
8079
  .attr('y1', rowsTop).attr('y2', rowsBottom)
7971
- .attr('stroke', ink).attr('stroke-opacity', 0.7).attr('stroke-width', 1);
8080
+ .style('stroke', ink).style('stroke-opacity', 0.7).style('stroke-width', 1);
7972
8081
  }
7973
8082
 
7974
8083
  // Scored over the visible WINDOW only -- the whole alignment would
@@ -8007,9 +8116,9 @@ function (root, d3, forester, phyloXml) {
8007
8116
  g.append('line')
8008
8117
  .attr('x1', from).attr('x2', guideEnd)
8009
8118
  .attr('y1', d.x).attr('y2', d.x)
8010
- .attr('stroke', ink)
8011
- .attr('stroke-width', 1)
8012
- .attr('stroke-dasharray', '2,3')
8119
+ .style('stroke', ink)
8120
+ .style('stroke-width', 1)
8121
+ .style('stroke-dasharray', '2,3')
8013
8122
  .style('stroke-opacity', 0.35)
8014
8123
  .style('pointer-events', 'none');
8015
8124
  }
@@ -8019,7 +8128,7 @@ function (root, d3, forester, phyloXml) {
8019
8128
  let strip = floatStripGroup('aptx-msa-strip', _clusterH, stripH);
8020
8129
  strip.append('rect').attr('x', Math.round(originX) - 2).attr('y', _clusterH)
8021
8130
  .attr('width', trackW + 4).attr('height', stripH)
8022
- .attr('fill', _state.backgroundColorDefault);
8131
+ .style('fill', _state.backgroundColorDefault);
8023
8132
  let consTop = _clusterH + MSA_CONS_TOP_GAP;
8024
8133
  // A caption on the panel's own background, so it stays readable over
8025
8134
  // whatever the band happens to draw underneath it.
@@ -8032,9 +8141,9 @@ function (root, d3, forester, phyloXml) {
8032
8141
  let xe = Math.round(originX) + trackW - 3;
8033
8142
  strip.append('rect').attr('x', xe - w).attr('y', y - 8)
8034
8143
  .attr('width', w).attr('height', 11)
8035
- .attr('fill', _state.backgroundColorDefault).attr('fill-opacity', 0.85);
8144
+ .style('fill', _state.backgroundColorDefault).style('fill-opacity', 0.85);
8036
8145
  strip.append('text').attr('x', xe - 3).attr('y', y)
8037
- .attr('text-anchor', 'end')
8146
+ .style('text-anchor', 'end')
8038
8147
  .style('font-size', '8px').style('fill', ink).style('fill-opacity', 0.9)
8039
8148
  .text(txt);
8040
8149
  };
@@ -8051,7 +8160,7 @@ function (root, d3, forester, phyloXml) {
8051
8160
  strip.append('line')
8052
8161
  .attr('x1', Math.round(originX)).attr('x2', Math.round(originX) + trackW)
8053
8162
  .attr('y1', floor).attr('y2', floor)
8054
- .attr('stroke', ink).attr('stroke-opacity', 0.5).attr('stroke-width', 1);
8163
+ .style('stroke', ink).style('stroke-opacity', 0.5).style('stroke-width', 1);
8055
8164
  for (let i = 0; i < visible; ++i) {
8056
8165
  let col = logo.columns[i];
8057
8166
  if (!col || !(col.height > 0)) {
@@ -8073,7 +8182,7 @@ function (root, d3, forester, phyloXml) {
8073
8182
  .attr('transform', 'translate(' + (x0 + ((x1 - x0) / 2)) + ','
8074
8183
  + (y - (glyph.descent * sy)) + ')'
8075
8184
  + ' scale(' + (Math.max(1, x1 - x0) / glyph.width) + ',' + sy + ')')
8076
- .attr('text-anchor', 'middle')
8185
+ .style('text-anchor', 'middle')
8077
8186
  .style('font-family', 'monospace')
8078
8187
  .style('font-size', MSA_LOGO_FONT_PX + 'px')
8079
8188
  .style('fill', rgb === null ? ink : 'rgb(' + rgb.join(',') + ')')
@@ -8086,7 +8195,7 @@ function (root, d3, forester, phyloXml) {
8086
8195
  } else {
8087
8196
  strip.append('rect').attr('x', Math.round(originX)).attr('y', consTop)
8088
8197
  .attr('width', trackW).attr('height', MSA_CONS_BAR_H)
8089
- .attr('fill', ink).attr('fill-opacity', 0.08);
8198
+ .style('fill', ink).style('fill-opacity', 0.08);
8090
8199
  for (let i = 0; i < visible; ++i) {
8091
8200
  let score = cons.scores[i] || 0;
8092
8201
  if (score <= 0) {
@@ -8097,7 +8206,7 @@ function (root, d3, forester, phyloXml) {
8097
8206
  let x1 = Math.round(originX + ((i + 1) * cw));
8098
8207
  strip.append('rect').attr('x', x0).attr('y', (consTop + MSA_CONS_BAR_H) - bh)
8099
8208
  .attr('width', Math.max(1, x1 - x0)).attr('height', bh)
8100
- .attr('fill', ink).attr('fill-opacity', 0.7);
8209
+ .style('fill', ink).style('fill-opacity', 0.7);
8101
8210
  }
8102
8211
  caption('Consensus identity (n = ' + n + ')', consTop + 9);
8103
8212
  for (let i = 0; i < visible; ++i) {
@@ -8107,7 +8216,7 @@ function (root, d3, forester, phyloXml) {
8107
8216
  }
8108
8217
  strip.append('text').attr('x', Math.round(originX + (i * cw)) + (cw / 2))
8109
8218
  .attr('y', consTop + MSA_CONS_BAR_H + 10)
8110
- .attr('text-anchor', 'middle')
8219
+ .style('text-anchor', 'middle')
8111
8220
  .style('font-family', 'monospace').style('font-size', '8px')
8112
8221
  .style('fill', ink)
8113
8222
  .text(cc);
@@ -8119,7 +8228,7 @@ function (root, d3, forester, phyloXml) {
8119
8228
  let rulerY = consTop + (logo ? MSA_LOGO_CAP_H + MSA_LOGO_H : MSA_CONS_BAR_H + MSA_CONS_ROW_H);
8120
8229
  strip.append('line').attr('x1', Math.round(originX)).attr('x2', Math.round(originX) + trackW)
8121
8230
  .attr('y1', rulerY).attr('y2', rulerY)
8122
- .attr('stroke', ink).attr('stroke-opacity', 0.8).attr('stroke-width', 1);
8231
+ .style('stroke', ink).style('stroke-opacity', 0.8).style('stroke-width', 1);
8123
8232
  let lastRight = -Infinity;
8124
8233
  let drawTick = function (c, force) {
8125
8234
  let i = c - 1 - offset;
@@ -8128,11 +8237,11 @@ function (root, d3, forester, phyloXml) {
8128
8237
  }
8129
8238
  let x = Math.round(originX + (i * cw)) + (cw / 2);
8130
8239
  strip.append('line').attr('x1', x).attr('x2', x).attr('y1', rulerY).attr('y2', rulerY + 4)
8131
- .attr('stroke', ink).attr('stroke-opacity', 0.8).attr('stroke-width', 1);
8240
+ .style('stroke', ink).style('stroke-opacity', 0.8).style('stroke-width', 1);
8132
8241
  let label = String(c);
8133
8242
  let half = label.length * 2.8;
8134
8243
  if (force || (x - half) >= (lastRight + 4)) {
8135
- strip.append('text').attr('x', x).attr('y', rulerY + 14).attr('text-anchor', 'middle')
8244
+ strip.append('text').attr('x', x).attr('y', rulerY + 14).style('text-anchor', 'middle')
8136
8245
  .style('font-size', '9px').style('fill', ink)
8137
8246
  .text(label);
8138
8247
  lastRight = x + half;
@@ -8149,7 +8258,7 @@ function (root, d3, forester, phyloXml) {
8149
8258
  g.append('rect').attr('class', 'aptx-msa-hover')
8150
8259
  .attr('x', Math.round(originX)).attr('y', rowsTop)
8151
8260
  .attr('width', trackW).attr('height', Math.max(1, rowsBottom - rowsTop))
8152
- .attr('fill', 'transparent')
8261
+ .style('fill', 'transparent')
8153
8262
  .on('mousemove', msaHoverMove)
8154
8263
  .on('mouseout', msaHoverOut)
8155
8264
  .on('wheel', function (event) {
@@ -8651,10 +8760,14 @@ function (root, d3, forester, phyloXml) {
8651
8760
  // that came and went with the zoom level. Both strokes are a constant
8652
8761
  // screen width, so a zoom neither fattens nor loses them.
8653
8762
  function paintHeatmapCell(el, fill, bordered) {
8654
- el.attr('fill', fill)
8655
- .attr('stroke', bordered ? heatmapBorderColor(fill) : fill)
8656
- .attr('stroke-width', bordered ? HEATMAP_BORDER_W : HEATMAP_SEAL_W)
8657
- .attr('vector-effect', 'non-scaling-stroke');
8763
+ el.style('fill', fill)
8764
+ .style('stroke', bordered ? heatmapBorderColor(fill) : fill)
8765
+ .style('stroke-width', bordered ? HEATMAP_BORDER_W : HEATMAP_SEAL_W)
8766
+ // ... said twice: as a style for the page, as everything here is,
8767
+ // and as an attribute for canvg, which draws the PNG and reads this
8768
+ // from the attribute alone (as a style only, every border came out
8769
+ // at the export's scale, four times as wide)
8770
+ .style('vector-effect', 'non-scaling-stroke').attr('vector-effect', 'non-scaling-stroke');
8658
8771
  return el;
8659
8772
  }
8660
8773
 
@@ -8784,8 +8897,8 @@ function (root, d3, forester, phyloXml) {
8784
8897
  let rect = g.append('rect').attr('x', x0).attr('y', cy)
8785
8898
  .attr('width', Math.max(1, x1 - x0)).attr('height', rh);
8786
8899
  if (runFill === HEATMAP_BLANK) {
8787
- rect.attr('fill', _state.backgroundColorDefault)
8788
- .attr('stroke', ink).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
8900
+ rect.style('fill', _state.backgroundColorDefault)
8901
+ .style('stroke', ink).style('stroke-opacity', 0.45).style('stroke-width', 1);
8789
8902
  } else {
8790
8903
  paintHeatmapCell(rect, runFill, bordered);
8791
8904
  }
@@ -8817,8 +8930,8 @@ function (root, d3, forester, phyloXml) {
8817
8930
  g.append('rect')
8818
8931
  .attr('x', Math.round(originX)).attr('y', rowsTop)
8819
8932
  .attr('width', trackW).attr('height', Math.max(1, rowsBottom - rowsTop))
8820
- .attr('fill', 'none')
8821
- .attr('stroke', ink).attr('stroke-opacity', 0.55).attr('stroke-width', 1);
8933
+ .style('fill', 'none')
8934
+ .style('stroke', ink).style('stroke-opacity', 0.55).style('stroke-width', 1);
8822
8935
 
8823
8936
  // ---- the clustering behind the column order, above the grid ----
8824
8937
  // Drawn with the cells, in the tree's own group, so it zooms, pans and
@@ -8871,9 +8984,9 @@ function (root, d3, forester, phyloXml) {
8871
8984
  }
8872
8985
  g.append('path').attr('class', 'aptx-heatmap-dendro')
8873
8986
  .attr('d', path)
8874
- .attr('fill', 'none')
8875
- .attr('stroke', ink)
8876
- .attr('stroke-width', Math.max(1, _state.branchWidthDefault))
8987
+ .style('fill', 'none')
8988
+ .style('stroke', ink)
8989
+ .style('stroke-width', Math.max(1, _state.branchWidthDefault))
8877
8990
  .style('pointer-events', 'none');
8878
8991
  }
8879
8992
 
@@ -8902,9 +9015,9 @@ function (root, d3, forester, phyloXml) {
8902
9015
  g.append('line')
8903
9016
  .attr('x1', from).attr('x2', guideEnd)
8904
9017
  .attr('y1', d.x).attr('y2', d.x)
8905
- .attr('stroke', ink)
8906
- .attr('stroke-width', 1)
8907
- .attr('stroke-dasharray', '2,3')
9018
+ .style('stroke', ink)
9019
+ .style('stroke-width', 1)
9020
+ .style('stroke-dasharray', '2,3')
8908
9021
  .style('stroke-opacity', 0.35)
8909
9022
  .style('pointer-events', 'none');
8910
9023
  }
@@ -8916,7 +9029,7 @@ function (root, d3, forester, phyloXml) {
8916
9029
  let strip = floatStripGroup('aptx-heatmap-strip', _clusterH, heatmapBottomReserve());
8917
9030
  strip.append('rect').attr('x', Math.round(originX) - 2).attr('y', _clusterH)
8918
9031
  .attr('width', trackW + 4).attr('height', heatmapStripHeight())
8919
- .attr('fill', _state.backgroundColorDefault);
9032
+ .style('fill', _state.backgroundColorDefault);
8920
9033
  let labelTop = _clusterH + HEATMAP_HEAD_GAP;
8921
9034
  let headH = heatmapHeadHeight();
8922
9035
  if (headH > 0) {
@@ -8927,7 +9040,7 @@ function (root, d3, forester, phyloXml) {
8927
9040
  .attr('transform', 'translate(' + (originX + (i * cw) + (cw / 2)) + ','
8928
9041
  + labelTop + ') rotate(-90)')
8929
9042
  .attr('dy', '0.32em')
8930
- .attr('text-anchor', 'end')
9043
+ .style('text-anchor', 'end')
8931
9044
  .style('font', font)
8932
9045
  .style('fill', _state.labelColorDefault)
8933
9046
  .text(clipTextToWidth(col.label, font, HEATMAP_LABEL_MAX_PX));
@@ -8944,20 +9057,20 @@ function (root, d3, forester, phyloXml) {
8944
9057
  VIS_COLOR_RAMP.forEach(function (c, i) {
8945
9058
  grad.append('stop')
8946
9059
  .attr('offset', ((i * 100) / (VIS_COLOR_RAMP.length - 1)) + '%')
8947
- .attr('stop-color', c);
9060
+ .style('stop-color', c);
8948
9061
  });
8949
9062
  strip.append('rect')
8950
9063
  .attr('x', Math.round(originX)).attr('y', scaleTop)
8951
9064
  .attr('width', barW).attr('height', HEATMAP_SCALE_BAR_H)
8952
- .attr('fill', 'url(#' + gradId + ')')
8953
- .attr('stroke', ink).attr('stroke-opacity', 0.5).attr('stroke-width', 1);
9065
+ .style('fill', 'url(#' + gradId + ')')
9066
+ .style('stroke', ink).style('stroke-opacity', 0.5).style('stroke-width', 1);
8954
9067
  let numY = scaleTop + HEATMAP_SCALE_BAR_H + 10;
8955
9068
  strip.append('text').attr('x', Math.round(originX)).attr('y', numY)
8956
- .attr('text-anchor', 'start')
9069
+ .style('text-anchor', 'start')
8957
9070
  .style('font-size', '9px').style('fill', ink)
8958
9071
  .text(heatmapNum(model.min));
8959
9072
  strip.append('text').attr('x', Math.round(originX) + barW).attr('y', numY)
8960
- .attr('text-anchor', 'end')
9073
+ .style('text-anchor', 'end')
8961
9074
  .style('font-size', '9px').style('fill', ink)
8962
9075
  .text(heatmapNum(model.max));
8963
9076
  // the caption: what the matrix is showing, and -- when it is windowed
@@ -8975,11 +9088,11 @@ function (root, d3, forester, phyloXml) {
8975
9088
  if ((rightEdge - cursor) >= keyW) {
8976
9089
  strip.append('rect').attr('x', cursor).attr('y', scaleTop)
8977
9090
  .attr('width', HEATMAP_SCALE_BAR_H).attr('height', HEATMAP_SCALE_BAR_H)
8978
- .attr('fill', _state.backgroundColorDefault)
8979
- .attr('stroke', ink).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
9091
+ .style('fill', _state.backgroundColorDefault)
9092
+ .style('stroke', ink).style('stroke-opacity', 0.45).style('stroke-width', 1);
8980
9093
  strip.append('text').attr('x', cursor + HEATMAP_SCALE_BAR_H + 4)
8981
9094
  .attr('y', scaleTop + HEATMAP_SCALE_BAR_H)
8982
- .attr('text-anchor', 'start')
9095
+ .style('text-anchor', 'start')
8983
9096
  .style('font', capFont).style('fill', ink).style('fill-opacity', 0.85)
8984
9097
  .text(HEATMAP_NOT_ASSESSED);
8985
9098
  cursor += keyW + 10;
@@ -8987,7 +9100,7 @@ function (root, d3, forester, phyloXml) {
8987
9100
  }
8988
9101
  if ((rightEdge - cursor) > 60) {
8989
9102
  strip.append('text').attr('x', cursor).attr('y', scaleTop + HEATMAP_SCALE_BAR_H)
8990
- .attr('text-anchor', 'start')
9103
+ .style('text-anchor', 'start')
8991
9104
  .style('font', capFont).style('fill', ink).style('fill-opacity', 0.85)
8992
9105
  .text(clipTextToWidth(caption, capFont, rightEdge - cursor));
8993
9106
  }
@@ -8996,7 +9109,7 @@ function (root, d3, forester, phyloXml) {
8996
9109
  g.append('rect').attr('class', 'aptx-heatmap-hover')
8997
9110
  .attr('x', Math.round(originX)).attr('y', rowsTop)
8998
9111
  .attr('width', trackW).attr('height', Math.max(1, rowsBottom - rowsTop))
8999
- .attr('fill', 'transparent')
9112
+ .style('fill', 'transparent')
9000
9113
  .on('mousemove', heatmapHoverMove)
9001
9114
  .on('mouseout', heatmapHoverOut)
9002
9115
  .on('wheel', function (event) {
@@ -9138,12 +9251,12 @@ function (root, d3, forester, phyloXml) {
9138
9251
  let path = g.append('path')
9139
9252
  .attr('d', heatmapSector(edge[runStart], a1, r0, r1 + bleed));
9140
9253
  if (runFill === HEATMAP_BLANK) {
9141
- path.attr('fill', _state.backgroundColorDefault)
9142
- .attr('stroke', ink).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
9254
+ path.style('fill', _state.backgroundColorDefault)
9255
+ .style('stroke', ink).style('stroke-opacity', 0.45).style('stroke-width', 1);
9143
9256
  } else if (bordered) {
9144
9257
  paintHeatmapCell(path, runFill, true);
9145
9258
  } else {
9146
- path.attr('fill', runFill); // the bleed already closes the seams here
9259
+ path.style('fill', runFill); // the bleed already closes the seams here
9147
9260
  }
9148
9261
  runStart = -1;
9149
9262
  runFill = null;
@@ -9169,7 +9282,7 @@ function (root, d3, forester, phyloXml) {
9169
9282
  [start, start + (columns.length * ringW)].forEach(function (r) {
9170
9283
  g.append('path')
9171
9284
  .attr('d', heatmapSector(edge[0], edge[n], r - 0.5, r + 0.5))
9172
- .attr('fill', ink).attr('fill-opacity', 0.5);
9285
+ .style('fill', ink).style('fill-opacity', 0.5);
9173
9286
  });
9174
9287
 
9175
9288
  // Each ring's name, laid along the ring at the fan's seam -- the gap
@@ -9193,7 +9306,7 @@ function (root, d3, forester, phyloXml) {
9193
9306
  g.append('text')
9194
9307
  .attr('transform', 'translate(' + xy[0].toFixed(1) + ',' + xy[1].toFixed(1) + ') '
9195
9308
  + 'rotate(' + (deg + (flip ? 180 : 0)).toFixed(1) + ')')
9196
- .attr('text-anchor', 'middle')
9309
+ .style('text-anchor', 'middle')
9197
9310
  .attr('dy', '0.32em')
9198
9311
  .style('font', font)
9199
9312
  .style('fill', _state.labelColorDefault)
@@ -9205,7 +9318,7 @@ function (root, d3, forester, phyloXml) {
9205
9318
  g.append('path')
9206
9319
  .attr('class', 'aptx-heatmap-hover')
9207
9320
  .attr('d', heatmapSector(edge[0], edge[n], start, start + (columns.length * ringW)))
9208
- .attr('fill', 'transparent')
9321
+ .style('fill', 'transparent')
9209
9322
  .on('mousemove', heatmapHoverMove)
9210
9323
  .on('mouseout', heatmapHoverOut);
9211
9324
 
@@ -9259,31 +9372,31 @@ function (root, d3, forester, phyloXml) {
9259
9372
  VIS_COLOR_RAMP.forEach(function (c, i) {
9260
9373
  grad.append('stop')
9261
9374
  .attr('offset', ((i * 100) / (VIS_COLOR_RAMP.length - 1)) + '%')
9262
- .attr('stop-color', c);
9375
+ .style('stop-color', c);
9263
9376
  });
9264
9377
  let barY = y + PAD;
9265
9378
  g.append('rect').attr('x', x + PAD).attr('y', barY)
9266
9379
  .attr('width', BAR_W).attr('height', HEATMAP_SCALE_BAR_H)
9267
- .attr('fill', 'url(#' + gradId + ')')
9268
- .attr('stroke', frame).attr('stroke-opacity', 0.5).attr('stroke-width', 1);
9380
+ .style('fill', 'url(#' + gradId + ')')
9381
+ .style('stroke', frame).style('stroke-opacity', 0.5).style('stroke-width', 1);
9269
9382
  let line = barY + HEATMAP_SCALE_BAR_H + FS + 2;
9270
- g.append('text').attr('x', x + PAD).attr('y', line).attr('text-anchor', 'start')
9383
+ g.append('text').attr('x', x + PAD).attr('y', line).style('text-anchor', 'start')
9271
9384
  .style('font', capFont).style('fill', ink).text(heatmapNum(m.min));
9272
- g.append('text').attr('x', x + PAD + BAR_W).attr('y', line).attr('text-anchor', 'end')
9385
+ g.append('text').attr('x', x + PAD + BAR_W).attr('y', line).style('text-anchor', 'end')
9273
9386
  .style('font', capFont).style('fill', ink).text(heatmapNum(m.max));
9274
9387
  if (blanks) {
9275
9388
  line += FS + 4;
9276
9389
  g.append('rect').attr('x', x + PAD).attr('y', line - HEATMAP_SCALE_BAR_H)
9277
9390
  .attr('width', HEATMAP_SCALE_BAR_H).attr('height', HEATMAP_SCALE_BAR_H)
9278
- .attr('fill', _state.backgroundColorDefault)
9279
- .attr('stroke', frame).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
9391
+ .style('fill', _state.backgroundColorDefault)
9392
+ .style('stroke', frame).style('stroke-opacity', 0.45).style('stroke-width', 1);
9280
9393
  g.append('text').attr('x', x + PAD + HEATMAP_SCALE_BAR_H + 4).attr('y', line)
9281
- .attr('text-anchor', 'start')
9394
+ .style('text-anchor', 'start')
9282
9395
  .style('font', capFont).style('fill', ink).style('fill-opacity', 0.85)
9283
9396
  .text(HEATMAP_NOT_ASSESSED);
9284
9397
  }
9285
9398
  line += FS + 4;
9286
- g.append('text').attr('x', x + PAD).attr('y', line).attr('text-anchor', 'start')
9399
+ g.append('text').attr('x', x + PAD).attr('y', line).style('text-anchor', 'start')
9287
9400
  .style('font', capFont).style('fill', ink).style('fill-opacity', 0.85)
9288
9401
  .text(caption);
9289
9402
  }
@@ -9878,7 +9991,7 @@ function (root, d3, forester, phyloXml) {
9878
9991
  let gap = _state.nodeLabelGap;
9879
9992
  let flip = radialDisplay() && labelFlip(d);
9880
9993
  if (_state.circularDisplay && _radial) {
9881
- t.attr('text-anchor', flip ? 'end' : 'start')
9994
+ t.style('text-anchor', flip ? 'end' : 'start')
9882
9995
  .attr('x', flip ? -gap : gap).attr('dy', '0.32em');
9883
9996
  if (_radialLabelsHorizontal) {
9884
9997
  let p = radialXY(d.x, d.y);
@@ -9891,12 +10004,12 @@ function (root, d3, forester, phyloXml) {
9891
10004
  } else if (radialDisplay()) {
9892
10005
  let r = radialRadius(far) - radialRadius(d.y) + gap;
9893
10006
  t.attr('transform', 'rotate(' + labelAngleDeg(d) + ') translate(' + r + ',0)' + (flip ? ' rotate(180)' : ''))
9894
- .attr('text-anchor', flip ? 'end' : 'start')
10007
+ .style('text-anchor', flip ? 'end' : 'start')
9895
10008
  .attr('x', 0).attr('dy', '0.32em');
9896
10009
  } else {
9897
10010
  let column = (_state.phylogram && _state.alignPhylogram) ? _w : far;
9898
10011
  t.attr('transform', null)
9899
- .attr('text-anchor', 'start')
10012
+ .style('text-anchor', 'start')
9900
10013
  .attr('x', column - d.y + gap)
9901
10014
  .attr('dy', (0.3 * fs) + 'px');
9902
10015
  }
@@ -10370,7 +10483,7 @@ function (root, d3, forester, phyloXml) {
10370
10483
  try {
10371
10484
  _settings.onViewChange(state, encoded);
10372
10485
  } catch (e) {
10373
- console.error(ERROR + 'onViewChange threw: ' + (e && e.message ? e.message : e));
10486
+ say('problem', 'error', ERROR + 'onViewChange threw: ' + (e && e.message ? e.message : e));
10374
10487
  }
10375
10488
  }
10376
10489
 
@@ -11163,6 +11276,9 @@ function (root, d3, forester, phyloXml) {
11163
11276
  let svg = clockSvg(body, 'svg', 'aptx-clock-svg');
11164
11277
  svg.setAttribute('width', CLOCK_PLOT_W);
11165
11278
  svg.setAttribute('height', CLOCK_PLOT_H);
11279
+ // inline as well: an attribute gives way to a page's rule for a bare "svg" (sizeTreeSvg)
11280
+ svg.style.width = CLOCK_PLOT_W + 'px';
11281
+ svg.style.height = CLOCK_PLOT_H + 'px';
11166
11282
  svg.setAttribute('viewBox', '0 0 ' + CLOCK_PLOT_W + ' ' + CLOCK_PLOT_H);
11167
11283
  svg.setAttribute('role', 'img');
11168
11284
  svg.setAttribute('aria-label', 'Clock plot: each tip\'s date against its divergence from the root');
@@ -11489,7 +11605,7 @@ function (root, d3, forester, phyloXml) {
11489
11605
  let t = clockSvg(cp.axes, 'text', cls);
11490
11606
  t.setAttribute('x', tx);
11491
11607
  t.setAttribute('y', ty);
11492
- t.setAttribute('text-anchor', anchor);
11608
+ t.style.setProperty('text-anchor', anchor);
11493
11609
  t.textContent = s;
11494
11610
  return t;
11495
11611
  };
@@ -12282,8 +12398,8 @@ function (root, d3, forester, phyloXml) {
12282
12398
  made[id] = true;
12283
12399
  let grad = d3.select(defs).append('linearGradient').attr('id', id)
12284
12400
  .attr('x1', 0).attr('y1', 0).attr('x2', 0).attr('y2', 1);
12285
- grad.append('stop').attr('offset', '0%').attr('stop-color', forester.domainLighten(base, 0.12));
12286
- grad.append('stop').attr('offset', '100%').attr('stop-color', forester.domainDarken(base, 0.10));
12401
+ grad.append('stop').attr('offset', '0%').style('stop-color', forester.domainLighten(base, 0.12));
12402
+ grad.append('stop').attr('offset', '100%').style('stop-color', forester.domainDarken(base, 0.10));
12287
12403
  }
12288
12404
  return 'url(#' + id + ')';
12289
12405
  }
@@ -12464,7 +12580,7 @@ function (root, d3, forester, phyloXml) {
12464
12580
  el.setAttribute('x', t.x);
12465
12581
  el.setAttribute('y', t.y);
12466
12582
  el.setAttribute('dy', '0.35em');
12467
- el.setAttribute('text-anchor', 'middle');
12583
+ el.style.setProperty('text-anchor', 'middle');
12468
12584
  el.style.font = t.font;
12469
12585
  el.style.fill = t.fill;
12470
12586
  el.textContent = t.text;
@@ -13177,7 +13293,7 @@ function (root, d3, forester, phyloXml) {
13177
13293
  el.setAttribute('x', t.x);
13178
13294
  el.setAttribute('y', t.y);
13179
13295
  el.setAttribute('dy', '0.35em');
13180
- el.setAttribute('text-anchor', 'middle');
13296
+ el.style.setProperty('text-anchor', 'middle');
13181
13297
  el.style.font = t.font;
13182
13298
  el.style.fill = t.fill;
13183
13299
  el.textContent = t.text;
@@ -13592,7 +13708,7 @@ function (root, d3, forester, phyloXml) {
13592
13708
  });
13593
13709
  g.append('text')
13594
13710
  .attr('x', x + (bar.px / 2)).attr('y', y - 6)
13595
- .attr('text-anchor', 'middle')
13711
+ .style('text-anchor', 'middle')
13596
13712
  .style('font', fs + 'px ' + FONT_DEFAULTS)
13597
13713
  .style('fill', _state.labelColorDefault)
13598
13714
  .text(bar.label);
@@ -13671,8 +13787,8 @@ function (root, d3, forester, phyloXml) {
13671
13787
  function gridLineAt(x) {
13672
13788
  grid.append('line').attr('x1', x).attr('x2', x)
13673
13789
  .attr('y1', 0).attr('y2', axisTop)
13674
- .attr('stroke', ink).attr('stroke-opacity', 0.18)
13675
- .attr('stroke-width', 1);
13790
+ .style('stroke', ink).style('stroke-opacity', 0.18)
13791
+ .style('stroke-width', 1);
13676
13792
  }
13677
13793
 
13678
13794
  let sc = info.type === 'calendar' ? -corr : corr;
@@ -13692,11 +13808,11 @@ function (root, d3, forester, phyloXml) {
13692
13808
  let p1 = polarXY(a, radialRadius(x1));
13693
13809
  el = g.append('line').attr('x1', p0[0]).attr('y1', p0[1])
13694
13810
  .attr('x2', p1[0]).attr('y2', p1[1])
13695
- .attr('stroke', fill).attr('stroke-width', thick);
13811
+ .style('stroke', fill).style('stroke-width', thick);
13696
13812
  } else {
13697
13813
  el = g.append('rect').attr('x', Math.min(x0, x1)).attr('y', d.x - (thick / 2))
13698
13814
  .attr('width', Math.max(1, Math.abs(x1 - x0))).attr('height', thick)
13699
- .attr('fill', fill);
13815
+ .style('fill', fill);
13700
13816
  }
13701
13817
  el.datum(d).attr('class', cls);
13702
13818
  }
@@ -13715,7 +13831,7 @@ function (root, d3, forester, phyloXml) {
13715
13831
  q = [x, d.x + 4];
13716
13832
  }
13717
13833
  g.append('line').attr('x1', p[0]).attr('y1', p[1]).attr('x2', q[0]).attr('y2', q[1])
13718
- .attr('stroke', FOSSIL_BAR_COLOR).attr('stroke-width', 1)
13834
+ .style('stroke', FOSSIL_BAR_COLOR).style('stroke-width', 1)
13719
13835
  .datum(d).attr('class', 'aptx-fossil-cap');
13720
13836
  }
13721
13837
 
@@ -13787,7 +13903,7 @@ function (root, d3, forester, phyloXml) {
13787
13903
  let left = Math.min(x0, x1);
13788
13904
  ax.append('rect').attr('x', left - 2).attr('y', axisTop - 4)
13789
13905
  .attr('width', Math.abs(x1 - x0) + 4).attr('height', scaleAxisBottomReserve() + 4)
13790
- .attr('fill', _state.backgroundColorDefault);
13906
+ .style('fill', _state.backgroundColorDefault);
13791
13907
  }
13792
13908
  if (info.type === 'geologic') {
13793
13909
  let rootAge = info.rootAge;
@@ -13828,11 +13944,11 @@ function (root, d3, forester, phyloXml) {
13828
13944
  }
13829
13945
  ax.append('rect').attr('x', left).attr('y', rowY)
13830
13946
  .attr('width', w).attr('height', TIME_BAND_ROW_H)
13831
- .attr('fill', iv.color)
13832
- .attr('stroke', ink).attr('stroke-opacity', 0.5).attr('stroke-width', 0.5);
13947
+ .style('fill', iv.color)
13948
+ .style('stroke', ink).style('stroke-opacity', 0.5).style('stroke-width', 0.5);
13833
13949
  if ((iv.name.length * 5.5) + 4 <= w) {
13834
13950
  ax.append('text').attr('x', left + (w / 2)).attr('y', rowY + TIME_BAND_ROW_H - 3.5)
13835
- .attr('text-anchor', 'middle')
13951
+ .style('text-anchor', 'middle')
13836
13952
  .style('font-size', '9px')
13837
13953
  .style('fill', forester.msaLetterInk(hexToRgbTriple(iv.color)))
13838
13954
  .text(iv.name);
@@ -13843,7 +13959,7 @@ function (root, d3, forester, phyloXml) {
13843
13959
  let rulerY = axisTop + (2 * TIME_BAND_ROW_H) + 4;
13844
13960
  ax.append('line').attr('x1', xOfAge(rootAge)).attr('x2', xOfAge(youngBound))
13845
13961
  .attr('y1', rulerY).attr('y2', rulerY)
13846
- .attr('stroke', ink).attr('stroke-width', 1);
13962
+ .style('stroke', ink).style('stroke-width', 1);
13847
13963
  let labels = [];
13848
13964
  forester.maAxisTickValues(rootAge).forEach(function (v) {
13849
13965
  if (v >= youngBound - 1e-9 && v <= rootAge + 1e-9) {
@@ -13863,7 +13979,7 @@ function (root, d3, forester, phyloXml) {
13863
13979
  let x = xOfAge(l.age);
13864
13980
  ax.append('line').attr('x1', x).attr('x2', x)
13865
13981
  .attr('y1', rulerY).attr('y2', rulerY + 4)
13866
- .attr('stroke', ink).attr('stroke-width', 1);
13982
+ .style('stroke', ink).style('stroke-width', 1);
13867
13983
  let text = String(l.age);
13868
13984
  let half = text.length * 2.8;
13869
13985
  let ok = true;
@@ -13875,14 +13991,14 @@ function (root, d3, forester, phyloXml) {
13875
13991
  }
13876
13992
  if (ok || l.priority > 0) {
13877
13993
  ax.append('text').attr('x', x).attr('y', rulerY + 14)
13878
- .attr('text-anchor', 'middle')
13994
+ .style('text-anchor', 'middle')
13879
13995
  .style('font-size', '9px').style('fill', ink)
13880
13996
  .text(text);
13881
13997
  placed.push(x);
13882
13998
  }
13883
13999
  });
13884
14000
  ax.append('text').attr('x', xOfAge(youngBound) + 8).attr('y', rulerY + 14)
13885
- .attr('text-anchor', 'start')
14001
+ .style('text-anchor', 'start')
13886
14002
  .style('font-size', '9px').style('fill', ink)
13887
14003
  .text('Ma');
13888
14004
  } else {
@@ -13911,18 +14027,18 @@ function (root, d3, forester, phyloXml) {
13911
14027
  let rulerY = axisTop + 4;
13912
14028
  ax.append('line').attr('x1', xOfYear(rootYear)).attr('x2', xOfYear(present))
13913
14029
  .attr('y1', rulerY).attr('y2', rulerY)
13914
- .attr('stroke', ink).attr('stroke-width', 1);
14030
+ .style('stroke', ink).style('stroke-width', 1);
13915
14031
  let lastRight = -Infinity;
13916
14032
  forester.calendarTickYears(rootYear, present).forEach(function (yv) {
13917
14033
  let x = xOfYear(yv);
13918
14034
  ax.append('line').attr('x1', x).attr('x2', x)
13919
14035
  .attr('y1', rulerY).attr('y2', rulerY + 4)
13920
- .attr('stroke', ink).attr('stroke-width', 1);
14036
+ .style('stroke', ink).style('stroke-width', 1);
13921
14037
  let text = String(Math.round(yv));
13922
14038
  let half = text.length * 2.8;
13923
14039
  if ((x - half) >= lastRight + 4) {
13924
14040
  ax.append('text').attr('x', x).attr('y', rulerY + 14)
13925
- .attr('text-anchor', 'middle')
14041
+ .style('text-anchor', 'middle')
13926
14042
  .style('font-size', '9px').style('fill', ink)
13927
14043
  .text(text);
13928
14044
  lastRight = x + half;
@@ -14016,15 +14132,15 @@ function (root, d3, forester, phyloXml) {
14016
14132
  return;
14017
14133
  }
14018
14134
  behind.append('path').attr('d', ring.innerRadius(a).outerRadius(b)())
14019
- .attr('fill', iv.color).attr('fill-opacity', 0.35);
14135
+ .style('fill', iv.color).style('fill-opacity', 0.35);
14020
14136
  named.push({r: (a + b) / 2, span: b - a, text: iv.name, color: iv.color});
14021
14137
  });
14022
14138
  if (grid) {
14023
14139
  ticks.forEach(function (t) {
14024
14140
  let r = radiusOf(t.value);
14025
14141
  if (r > r0 + 0.5 && r < r1 - 0.5) {
14026
- grid.append('circle').attr('r', r).attr('fill', 'none')
14027
- .attr('stroke', ink).attr('stroke-opacity', 0.18).attr('stroke-width', 1);
14142
+ grid.append('circle').attr('r', r).style('fill', 'none')
14143
+ .style('stroke', ink).style('stroke-opacity', 0.18).style('stroke-width', 1);
14028
14144
  }
14029
14145
  });
14030
14146
  }
@@ -14043,7 +14159,7 @@ function (root, d3, forester, phyloXml) {
14043
14159
  let e0 = at(r0, 0);
14044
14160
  let e1 = at(r1, 0);
14045
14161
  ax.append('line').attr('x1', e0[0]).attr('y1', e0[1]).attr('x2', e1[0]).attr('y2', e1[1])
14046
- .attr('stroke', ink).attr('stroke-width', 1);
14162
+ .style('stroke', ink).style('stroke-width', 1);
14047
14163
  // numbers on one side of the ruler, interval names on the other; the
14048
14164
  // headline value first, then the round ticks outward, none overlapping
14049
14165
  function label(r, off, text, fill, backdrop) {
@@ -14053,9 +14169,9 @@ function (root, d3, forester, phyloXml) {
14053
14169
  let w = text.length * 5.5;
14054
14170
  if (backdrop) {
14055
14171
  t.append('rect').attr('x', -(w / 2) - 2).attr('y', -6).attr('width', w + 4).attr('height', 12)
14056
- .attr('rx', 2).attr('fill', _state.backgroundColorDefault).attr('fill-opacity', 0.85);
14172
+ .attr('rx', 2).style('fill', _state.backgroundColorDefault).style('fill-opacity', 0.85);
14057
14173
  }
14058
- t.append('text').attr('text-anchor', 'middle').attr('dominant-baseline', 'central')
14174
+ t.append('text').style('text-anchor', 'middle').style('dominant-baseline', 'central')
14059
14175
  .style('font-size', '9px').style('fill', fill).text(text);
14060
14176
  }
14061
14177
  let side = flip ? -1 : 1;
@@ -14068,7 +14184,7 @@ function (root, d3, forester, phyloXml) {
14068
14184
  let c0 = at(r, -4);
14069
14185
  let c1 = at(r, 4);
14070
14186
  ax.append('line').attr('x1', c0[0]).attr('y1', c0[1]).attr('x2', c1[0]).attr('y2', c1[1])
14071
- .attr('stroke', ink).attr('stroke-width', 1);
14187
+ .style('stroke', ink).style('stroke-width', 1);
14072
14188
  let half = t.text.length * 2.8;
14073
14189
  for (let i = 0; i < placed.length; ++i) {
14074
14190
  if (Math.abs(placed[i] - r) < (half * 2) + 6) {
@@ -14142,8 +14258,8 @@ function (root, d3, forester, phyloXml) {
14142
14258
  if (v > 0 && v < maxDist - 1e-9) {
14143
14259
  grid.append('line').attr('x1', xOf(v)).attr('x2', xOf(v))
14144
14260
  .attr('y1', 0).attr('y2', axisTop)
14145
- .attr('stroke', ink).attr('stroke-opacity', 0.18)
14146
- .attr('stroke-width', 1);
14261
+ .style('stroke', ink).style('stroke-opacity', 0.18)
14262
+ .style('stroke-width', 1);
14147
14263
  }
14148
14264
  });
14149
14265
  }
@@ -14155,23 +14271,23 @@ function (root, d3, forester, phyloXml) {
14155
14271
  // opaque, as the time axis's, so tips panned under it do not show
14156
14272
  ax.append('rect').attr('x', x0 - 2).attr('y', axisTop - 4)
14157
14273
  .attr('width', (x1 - x0) + 4).attr('height', reserve + 4)
14158
- .attr('fill', _state.backgroundColorDefault);
14274
+ .style('fill', _state.backgroundColorDefault);
14159
14275
  let rulerY = axisTop + 4;
14160
14276
  ax.append('line').attr('x1', x0).attr('x2', x1)
14161
14277
  .attr('y1', rulerY).attr('y2', rulerY)
14162
- .attr('stroke', ink).attr('stroke-width', 1);
14278
+ .style('stroke', ink).style('stroke-width', 1);
14163
14279
  // every tick drawn, its number only where it clears the last one
14164
14280
  let lastRight = -Infinity;
14165
14281
  ticks.forEach(function (v) {
14166
14282
  let x = xOf(v);
14167
14283
  ax.append('line').attr('x1', x).attr('x2', x)
14168
14284
  .attr('y1', rulerY).attr('y2', rulerY + 4)
14169
- .attr('stroke', ink).attr('stroke-width', 1);
14285
+ .style('stroke', ink).style('stroke-width', 1);
14170
14286
  let text = String(v);
14171
14287
  let half = text.length * 2.8;
14172
14288
  if ((x - half) >= lastRight + 4) {
14173
14289
  ax.append('text').attr('x', x).attr('y', rulerY + 14)
14174
- .attr('text-anchor', 'middle')
14290
+ .style('text-anchor', 'middle')
14175
14291
  .style('font-size', '9px').style('fill', ink)
14176
14292
  .text(text);
14177
14293
  lastRight = x + half;
@@ -14181,7 +14297,7 @@ function (root, d3, forester, phyloXml) {
14181
14297
  // past the ruler's end, and past the last number, which is
14182
14298
  // centred on that end
14183
14299
  ax.append('text').attr('x', Math.max(x1 + 8, lastRight + 6)).attr('y', rulerY + 14)
14184
- .attr('text-anchor', 'start')
14300
+ .style('text-anchor', 'start')
14185
14301
  .style('font-size', '9px').style('fill', ink)
14186
14302
  .text(unit);
14187
14303
  }
@@ -14763,8 +14879,7 @@ function (root, d3, forester, phyloXml) {
14763
14879
  _displayHeight = size.h;
14764
14880
  // the canvas has to follow, or the layout is computed for one
14765
14881
  // size and drawn on another
14766
- _baseSvg.attr('width', size.w);
14767
- _baseSvg.attr('height', size.h);
14882
+ sizeTreeSvg(_baseSvg, size.w, size.h);
14768
14883
  }
14769
14884
  }
14770
14885
  // Where the user dragged the legend to is their choice; a resize is no
@@ -15904,14 +16019,17 @@ function (root, d3, forester, phyloXml) {
15904
16019
  // The async clipboard API where the page allows it (a secure context);
15905
16020
  // otherwise the old select-and-copy, so a page served over plain http
15906
16021
  // still copies. Resolves to whether the text made it.
15907
- function copyText(text) {
16022
+ //
16023
+ // `host` is the open modal dialog the copy is made from, if any: under a
16024
+ // modal everything outside it is inert, a textarea on the body included.
16025
+ function copyText(text, host) {
15908
16026
  let legacy = function () {
15909
16027
  let ta = document.createElement('textarea');
15910
16028
  ta.value = text;
15911
16029
  ta.setAttribute('readonly', '');
15912
16030
  ta.style.position = 'fixed';
15913
16031
  ta.style.left = '-10000px';
15914
- document.body.appendChild(ta);
16032
+ (host || document.body).appendChild(ta);
15915
16033
  ta.select();
15916
16034
  let ok = false;
15917
16035
  try {
@@ -16650,9 +16768,11 @@ function (root, d3, forester, phyloXml) {
16650
16768
  case 'moon': body = glyphMoon(); break;
16651
16769
  default: throw new Error('unknown control-panel glyph: ' + kind);
16652
16770
  }
16653
- return '<svg class="aptx-glyph" viewBox="0 0 ' + w + ' 100" aria-hidden="true" focusable="false"'
16771
+ // written as an svg file would be, its paint in attributes; paintInline
16772
+ // makes inline styles of it, which no rule of a page outranks
16773
+ return paintInline('<svg class="aptx-glyph" viewBox="0 0 ' + w + ' 100" aria-hidden="true" focusable="false"'
16654
16774
  + ' fill="none" stroke="currentColor" stroke-width="' + sw + '"'
16655
- + ' stroke-linecap="' + cap + '" stroke-linejoin="' + join + '">' + body + '</svg>';
16775
+ + ' stroke-linecap="' + cap + '" stroke-linejoin="' + join + '">' + body + '</svg>');
16656
16776
  }
16657
16777
 
16658
16778
  // The desktop's UNCOLLAPSE_ALL glyph (ControlButtonIcon.paintUncollapse),
@@ -16669,6 +16789,214 @@ function (root, d3, forester, phyloXml) {
16669
16789
  + '<path d="M56,20 H94 M56,50 H94 M56,80 H94"/>';
16670
16790
  }
16671
16791
 
16792
+ // ===================== A host page's stylesheet =====================
16793
+ // The viewer is drawn inside somebody else's page, under that page's
16794
+ // stylesheet, and a rule of the host's wins wherever this sheet says
16795
+ // nothing. BV-BRC, 2026-10-09: ".Phylogeny svg { background:#FFFFFF }",
16796
+ // written when the only svg in that widget was the tree, put a white box
16797
+ // behind every icon of the panel. Measured after that, on pages nobody
16798
+ // had tried: a page set in capitals, or centred, set the dialogs and the
16799
+ // tooltip that way too; and under "* { box-sizing: border-box }", which
16800
+ // most pages have, the tooltip came out 22 px narrower.
16801
+ //
16802
+ // So the sheet opens by saying what it used to leave unsaid. These rules
16803
+ // come FIRST, and the general ones carry as little weight as will do:
16804
+ // every rule after them still has the last word. What they cannot do is
16805
+ // outrank a rule of the host's that is heavier still: one scoped by an
16806
+ // id, or by more classes than the rule here has, or marked !important.
16807
+ //
16808
+ // test/browser/host_css.html launches the viewer under real host sheets
16809
+ // and fails on any difference from a clean page. Something new that the
16810
+ // viewer hangs on the page goes into HOST_PROOF_ROOTS, and through it.
16811
+ const HOST_PROOF_ROOTS = ['.aptx-panel', '.aptx-dialog', '.aptx-node-menu', '.aptx-suggest', '.aptx-tip',
16812
+ '.aptx-busy', '.aptx-msa-nav'];
16813
+ // What paints a shape or sets a text, what shows or hides it, and what
16814
+ // would animate it: the properties a page's rule for a bare "path", "rect"
16815
+ // or "text" is likely to set. An svg ATTRIBUTE for one of these gives way
16816
+ // to any such rule, so the viewer states them as inline styles, and its
16817
+ // sheet says, for every shape of its svgs, "as a page that says nothing
16818
+ // has it" -- which for most of them is what the shape's parent has, and
16819
+ // so, in the end, what the svg itself has: the value beside each name,
16820
+ // where a parent hands it down. (Without one: not handed down; or stated
16821
+ // by the svg's own rule; or left to the page on purpose, as the cursor
16822
+ // and whether the thing is shown at all.)
16823
+ //
16824
+ // It also means that an ATTRIBUTE for one of these, on a shape of the
16825
+ // viewer's, no longer does anything, in any page: test/
16826
+ // visualization_test.js holds the drawing code to that, and test/browser/
16827
+ // host_css.html the page (with these rules taken out, nothing changes).
16828
+ // Markup with its paint in attributes goes through paintInline.
16829
+ //
16830
+ // Not here, and still attributes: where a shape is and how large (x, y,
16831
+ // d, r, transform), clip-path and mask.
16832
+ const SHAPE_PAINT = {
16833
+ 'fill': '#000', 'fill-opacity': '1', 'fill-rule': 'nonzero',
16834
+ 'stroke': 'none', 'stroke-width': '1', 'stroke-opacity': '1', 'stroke-dasharray': 'none',
16835
+ 'stroke-dashoffset': '0', 'stroke-linecap': 'butt', 'stroke-linejoin': 'miter', 'stroke-miterlimit': '4',
16836
+ 'paint-order': 'normal', 'text-anchor': 'start', 'dominant-baseline': 'auto', 'shape-rendering': 'auto',
16837
+ 'vector-effect': null, 'stop-color': null, 'stop-opacity': null, 'opacity': null, 'filter': null,
16838
+ 'mix-blend-mode': null, 'display': null, 'visibility': null, 'cursor': null, 'pointer-events': null,
16839
+ 'color': null, 'font': null, 'letter-spacing': null, 'word-spacing': null, 'text-transform': null,
16840
+ 'text-decoration': null, 'text-shadow': null, 'text-rendering': null, 'white-space': null,
16841
+ 'transition': null, 'animation': null
16842
+ };
16843
+ // What of that paint a FILE states as attributes again (paintAsAttributes,
16844
+ // for the SVG, PNG and PDF downloads). A file has no page to hold its
16845
+ // ground against, and not every program that opens one reads a style as
16846
+ // well as it reads an attribute: MuPDF takes a line's colour from a style
16847
+ // and not its opacity, and drew the faint lines of a time axis at full
16848
+ // strength. These are the paint properties SVG 1.1 has as attributes, and
16849
+ // vector-effect, which every release wrote as one. (paint-order is SVG
16850
+ // 2's: it stays the style it always was. So do the fonts.)
16851
+ const FILE_PAINT = ['fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-opacity',
16852
+ 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit',
16853
+ 'text-anchor', 'dominant-baseline', 'shape-rendering', 'vector-effect', 'stop-color', 'stop-opacity',
16854
+ 'opacity'];
16855
+ // The paints that are a colour, each with the attribute a colour's alpha
16856
+ // goes to in a file. SVG 1.1 has no colour with an alpha and no
16857
+ // "transparent", and a program that knows neither draws black: MuPDF put
16858
+ // a black slab over a heat map (the surface it listens to the pointer
16859
+ // with) and over every row of a legend, and a solid bar where a node's
16860
+ // age interval is a translucent one.
16861
+ const FILE_ALPHA = {'fill': 'fill-opacity', 'stroke': 'stroke-opacity', 'stop-color': 'stop-opacity'};
16862
+ // every kind of element an svg of the viewer's is made of: the rule names
16863
+ // them one by one, so that a browser never tries it on anything else
16864
+ const SHAPE_ELEMENTS = ['g', 'path', 'line', 'rect', 'circle', 'ellipse', 'polygon', 'polyline', 'text', 'tspan',
16865
+ 'textPath', 'use', 'image', 'a', 'defs', 'mask', 'clipPath', 'linearGradient', 'radialGradient', 'stop',
16866
+ 'symbol', 'marker', 'pattern', 'title', 'desc'];
16867
+ // Weight without rank. A selector made of element names alone weighs less
16868
+ // than one class however many names it has, so eight names that are no
16869
+ // element's, inside a ":not", make a rule outweigh every rule a page
16870
+ // writes with element names only ("label", "fieldset > legend", "*"),
16871
+ // whichever sheet came first, and still give way to any rule for a class.
16872
+ const ELEMENT_WEIGHT = ':not(' + new Array(8).fill('aptx-none').join(' ') + ')';
16873
+ // Where the browser's own focus ring is what shows the focus (the menus
16874
+ // light the entry instead), and the controls there that draw their own.
16875
+ const FOCUS_RING_ROOTS = ['.aptx-panel', '.aptx-dialog', '.aptx-msa-nav'];
16876
+ // (Every rule of this sheet that sets an outline has its control here: the
16877
+ // rule that gives the ring back outweighs them all, so one left out gets
16878
+ // the browser's ring on top of its own. A colour swatch draws an outline
16879
+ // under the pointer, and keeps the browser's ring otherwise.)
16880
+ const OWN_FOCUS = ['.aptx-cp-hex', '.aptx-reps-input', '.aptx-reorder-row', '.aptx-reps-button',
16881
+ '.aptx-msa-nav-jump', '.aptx-clampable', '.aptx-cp-sw:hover', 'input[type=text]', 'select'];
16882
+
16883
+ // Svg markup as a file has it, its paint in attributes, with that paint
16884
+ // moved into inline styles: an attribute gives way to any rule of the page
16885
+ // the markup is put in, and to the viewer's own "as a page that says
16886
+ // nothing has it" (SHAPE_PAINT). The icons and the logo go through here.
16887
+ // An attribute may say what a style may not: a font given piece by piece,
16888
+ // a size without its unit.
16889
+ function paintInline(markup) {
16890
+ let names = Object.keys(SHAPE_PAINT).concat(['font-family', 'font-size', 'font-style', 'font-weight']);
16891
+ let paint = new RegExp('\\s(' + names.join('|') + ')="([^"]*)"', 'g');
16892
+ return markup.replace(/<([a-zA-Z]+)(\s[^<>]*?)(\/?)>/g, function (all, tag, attributes, closed) {
16893
+ let styles = [];
16894
+ let rest = attributes.replace(paint, function (attribute, name, value) {
16895
+ let unitless = (name === 'font-size' || name === 'letter-spacing' || name === 'word-spacing')
16896
+ && /^-?[0-9.]+$/.test(value);
16897
+ styles.push(name + ':' + value + (unitless ? 'px' : ''));
16898
+ return '';
16899
+ });
16900
+ if (styles.length === 0) {
16901
+ return all;
16902
+ }
16903
+ // a style the element already has is said after, and so still has the last word
16904
+ let own = '';
16905
+ rest = rest.replace(/\sstyle="([^"]*)"/, function (attribute, value) {
16906
+ own = ';' + value;
16907
+ return '';
16908
+ });
16909
+ return '<' + tag + rest + ' style="' + styles.join(';') + own + '"' + closed + '>';
16910
+ });
16911
+ }
16912
+
16913
+ function hostProofCss() {
16914
+ let roots = HOST_PROOF_ROOTS.join(', ');
16915
+ // (the weight: BV-BRC's rule is ".Phylogeny svg", exactly as heavy as "svg.aptx-tree")
16916
+ let tree = 'svg.' + TREE_SVG_CLASS + ELEMENT_WEIGHT;
16917
+ // Every root and all that is in it (":where" adds no weight), svgs
16918
+ // apart: their shapes are drawn with attributes, which a rule saying
16919
+ // "revert" would undo. The part about the element itself stands last,
16920
+ // where a browser tries it first: a shape of the tree is turned away
16921
+ // at "svg *". (Measured, a whole page's styles worked out again: 8 ms
16922
+ // became 10 on a page of 20,000 elements around the viewer.)
16923
+ let everything = ':where(' + roots + ')' + ELEMENT_WEIGHT
16924
+ + ', :where(' + roots + ') :not(svg, svg *)' + ELEMENT_WEIGHT;
16925
+ // Every shape of every svg of the viewer's: the tree's, the icons',
16926
+ // the logo's, the clock plot's. With the weight of a class, as the
16927
+ // svgs themselves: "rect:hover", "text:first-child" and ".chart text"
16928
+ // all weigh one class and an element. So a rule of this sheet for a
16929
+ // shape names two classes (".aptx-dialog .aptx-clock-grid"), or it
16930
+ // loses to this one.
16931
+ let svgs = ':is(svg.' + TREE_SVG_CLASS + ', ' + HOST_PROOF_ROOTS.map(function (r) {
16932
+ return r + ' svg';
16933
+ }).join(', ') + ')';
16934
+ let shapes = SHAPE_ELEMENTS.map(function (element) {
16935
+ return svgs + ' ' + element + ELEMENT_WEIGHT;
16936
+ }).join(', ');
16937
+ let properties = Object.keys(SHAPE_PAINT);
16938
+ // where a shape's paint starts from, for what it does not state: said on the svg
16939
+ let start = properties.filter(function (name) {
16940
+ return SHAPE_PAINT[name] !== null;
16941
+ }).map(function (name) {
16942
+ return name + ':' + SHAPE_PAINT[name] + ';';
16943
+ }).join(' ');
16944
+ return ''
16945
+ // 1. Everything starts from the browser's own defaults, as in a
16946
+ // page that says nothing: a reset's "* { box-sizing:border-box }"
16947
+ // or "button { font: inherit }", a framework's "label { font-
16948
+ // weight: 700 }". In a page that does say nothing this rule
16949
+ // changes nothing; and every rule of this sheet for a class,
16950
+ // here or below, still wins (ELEMENT_WEIGHT).
16951
+ + everything + ' { all:revert; }'
16952
+ // An svg's own element likewise (its shapes are left alone),
16953
+ // and with the weight of a class: BV-BRC's rule was a class of
16954
+ // the page's and "svg". So an svg of the viewer's states its
16955
+ // size in the sheet or inline, never as attributes alone, and
16956
+ // its own rule names two classes (or it loses to this one).
16957
+ + ':is(' + roots + ') svg' + ELEMENT_WEIGHT + ' { all:revert; ' + start + ' }'
16958
+ // And a shape of an svg, in what paints it (SHAPE_PAINT): it
16959
+ // states that inline, so what a page says of a "path" or a
16960
+ // "text" is set aside for what the browser has, which for most
16961
+ // of these is what the shape's parent has.
16962
+ + shapes + ' { ' + properties.join(':revert; ') + ':revert; }'
16963
+ // Bootstrap 3 names tick boxes by their type, which outweighs
16964
+ // the rule above: the one tick box here that states no margin
16965
+ // of its own keeps the browser's, and a disabled one its cursor.
16966
+ + '.aptx-dialog .aptx-reorder-check { margin:revert; }'
16967
+ + '.aptx-dialog input[type=checkbox]:disabled, .aptx-dialog input[type=radio]:disabled { cursor:default; }'
16968
+ // 2. What a page hands down by inheritance stops at each root. The
16969
+ // values are the ones a page that says nothing has; a root's
16970
+ // own rule, further down, states its font, size and colour.
16971
+ // (Left to the page on purpose: direction, cursor, visibility,
16972
+ // pointer-events.)
16973
+ + roots + ', ' + tree + ' {'
16974
+ + ' color-scheme:light; accent-color:auto; caret-color:auto;'
16975
+ + ' font:400 11px system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;'
16976
+ + ' letter-spacing:normal; word-spacing:normal; text-align:start; text-align-last:auto; text-indent:0;'
16977
+ + ' text-transform:none; text-shadow:none; text-rendering:auto;'
16978
+ + ' -webkit-text-size-adjust:auto; text-size-adjust:auto;'
16979
+ + ' white-space:normal; word-break:normal; overflow-wrap:normal; hyphens:manual; tab-size:8;'
16980
+ + ' quotes:auto; list-style:disc outside none; }'
16981
+ // 3. The tree's own svg. Its shapes carry their paint inline; what
16982
+ // they inherit, and what a rule for a bare "svg" would set, is
16983
+ // said here. (Its width, height and font are inline: they are
16984
+ // the file's as well as the page's, see sizeTreeSvg and where
16985
+ // the svg is made. The background is the viewer's own rect.)
16986
+ // border-box, so that a border a page gives it on purpose fits
16987
+ // inside the size stated, in a page with a reset and without.
16988
+ // Nor does it move as a page's "* { transition: all .3s }" would
16989
+ // have it: the tree is redrawn frame by frame.
16990
+ + tree + ' { color:#000; background:none; max-width:none; box-sizing:border-box;'
16991
+ + ' vertical-align:baseline; transition:none; animation:none; ' + start + ' }'
16992
+ // "node" is every d3 example's class as well: ".node { font: 10px sans-serif; cursor: pointer }"
16993
+ + tree + ' g.node { font:inherit; cursor:inherit; }'
16994
+ // 4. The browser's own focus ring, back on whatever does not draw
16995
+ // one of its own: ":focus { outline:0 }" is in many a sheet.
16996
+ + ':is(' + FOCUS_RING_ROOTS.join(', ') + ') :focus-visible:not(' + OWN_FOCUS.join(', ') + ')'
16997
+ + ' { outline:revert; outline-offset:revert; }';
16998
+ }
16999
+
16672
17000
  function injectPanelStyles() {
16673
17001
  loadPanelTheme();
16674
17002
  if (document.getElementById(PANEL_STYLE_ID)) {
@@ -16680,7 +17008,7 @@ function (root, d3, forester, phyloXml) {
16680
17008
  + ' --p-line:#27343f; --p-line-strong:#35434f; --p-surface2:#202d38;'
16681
17009
  + ' --p-accent:#57a6ff; --p-accent-ink:#9cc7ff; --p-accent-weak:rgba(87,166,255,0.18);'
16682
17010
  + ' --p-shadow-sm:0 1px 2px rgba(0,0,0,0.4);';
16683
- let css = ''
17011
+ let css = hostProofCss()
16684
17012
  + '.aptx-panel {'
16685
17013
  + ' --p-bg: rgba(255,255,255,0.86); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2; --p-warn:#b15a0b;'
16686
17014
  + ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
@@ -16817,6 +17145,11 @@ function (root, d3, forester, phyloXml) {
16817
17145
  + '.aptx-dialog-close:hover { background:var(--p-accent-weak); color:var(--p-accent-ink);'
16818
17146
  + ' border-color:var(--p-accent); }'
16819
17147
  + '.aptx-dialog-body { padding:10px 13px 12px; overflow:auto; }'
17148
+ // a dialog that scrolls gets the panel's own scrollbar: the
17149
+ // browser's default track is white in the dark theme too
17150
+ + '.aptx-dialog-body::-webkit-scrollbar { width:9px; height:9px; }'
17151
+ + '.aptx-dialog-body::-webkit-scrollbar-thumb { background:var(--p-line-strong); border-radius:9px; border:2px solid var(--p-bg); }'
17152
+ + '.aptx-dialog-body::-webkit-scrollbar-track, .aptx-dialog-body::-webkit-scrollbar-corner { background:transparent; }'
16820
17153
  + '.aptx-cp-sv { position:relative; width:100%; box-sizing:border-box; height:104px; border-radius:7px;'
16821
17154
  + ' border:1px solid var(--p-line-strong); cursor:crosshair; touch-action:none; }'
16822
17155
  + '.aptx-cp-hue { position:relative; width:100%; box-sizing:border-box; height:13px; margin-top:9px;'
@@ -16871,7 +17204,8 @@ function (root, d3, forester, phyloXml) {
16871
17204
  + '.aptx-reps { line-height:1.35; }'
16872
17205
  + '.aptx-reps-lead { margin:0 0 4px; }'
16873
17206
  + '.aptx-reps-choice { display:flex; align-items:center; gap:6px; padding:2px 0; cursor:pointer; }'
16874
- + '.aptx-reps-choice input { accent-color:var(--p-accent); width:13px; height:13px; margin:0; flex:none; }'
17207
+ // (".aptx-dialog": Bootstrap 3's rule for a tick box weighs as much as ".aptx-reps-choice input")
17208
+ + '.aptx-dialog .aptx-reps-choice input { accent-color:var(--p-accent); width:13px; height:13px; margin:0; flex:none; }'
16875
17209
  + '.aptx-reps-disabled { color:var(--p-faint); cursor:default; }'
16876
17210
  + '.aptx-reps-input { display:block; box-sizing:border-box; width:100%; font:inherit; color:var(--p-ink);'
16877
17211
  + ' background:var(--p-surface2); border:1px solid var(--p-line-strong); border-radius:6px; padding:4px 7px; }'
@@ -16920,18 +17254,18 @@ function (root, d3, forester, phyloXml) {
16920
17254
  // node's in the tree and is set on the point itself
16921
17255
  + '.aptx-dialog-drag { cursor:move; touch-action:none; user-select:none; -webkit-user-select:none; }'
16922
17256
  + '.aptx-clock.aptx-dialog-body { overflow:visible; }'
16923
- + '.aptx-clock-svg { display:block; touch-action:none; user-select:none; -webkit-user-select:none; }'
16924
- + '.aptx-clock-svg text { font-size:10px; fill:var(--p-muted); font-variant-numeric:tabular-nums; }'
16925
- + '.aptx-clock-svg text.aptx-clock-title { font-size:10.5px; fill:var(--p-ink); }'
16926
- + '.aptx-clock-ground { fill:var(--p-surface2); }'
16927
- + '.aptx-clock-grid { stroke:var(--p-line); stroke-width:1; shape-rendering:crispEdges; }'
16928
- + '.aptx-clock-frame { stroke:var(--p-line-strong); stroke-width:1; shape-rendering:crispEdges; }'
16929
- + '.aptx-clock-line { stroke:var(--p-accent); stroke-width:1.6; }'
16930
- + '.aptx-clock-internal circle { fill:var(--p-faint); fill-opacity:0.55; }'
16931
- + '.aptx-clock-tips circle { fill:var(--p-ink); fill-opacity:0.8; }'
16932
- + '.aptx-clock-svg circle.aptx-clock-hit { fill-opacity:1; stroke-width:1.2; }'
16933
- + '.aptx-clock-ring { fill:none; stroke:var(--p-accent); stroke-width:1.8; pointer-events:none; }'
16934
- + '.aptx-clock-box { fill:var(--p-accent-weak); stroke:var(--p-accent); stroke-width:1;'
17257
+ + '.aptx-dialog .aptx-clock-svg { display:block; touch-action:none; user-select:none; -webkit-user-select:none; }'
17258
+ + '.aptx-dialog .aptx-clock-svg text { font-size:10px; fill:var(--p-muted); font-variant-numeric:tabular-nums; }'
17259
+ + '.aptx-dialog .aptx-clock-svg text.aptx-clock-title { font-size:10.5px; fill:var(--p-ink); }'
17260
+ + '.aptx-dialog .aptx-clock-ground { fill:var(--p-surface2); }'
17261
+ + '.aptx-dialog .aptx-clock-grid { stroke:var(--p-line); stroke-width:1; shape-rendering:crispEdges; }'
17262
+ + '.aptx-dialog .aptx-clock-frame { stroke:var(--p-line-strong); stroke-width:1; shape-rendering:crispEdges; }'
17263
+ + '.aptx-dialog .aptx-clock-line { stroke:var(--p-accent); stroke-width:1.6; }'
17264
+ + '.aptx-dialog .aptx-clock-internal circle { fill:var(--p-faint); fill-opacity:0.55; }'
17265
+ + '.aptx-dialog .aptx-clock-tips circle { fill:var(--p-ink); fill-opacity:0.8; }'
17266
+ + '.aptx-dialog .aptx-clock-svg circle.aptx-clock-hit { fill-opacity:1; stroke-width:1.2; }'
17267
+ + '.aptx-dialog .aptx-clock-ring { fill:none; stroke:var(--p-accent); stroke-width:1.8; pointer-events:none; }'
17268
+ + '.aptx-dialog .aptx-clock-box { fill:var(--p-accent-weak); stroke:var(--p-accent); stroke-width:1;'
16935
17269
  + ' shape-rendering:crispEdges; pointer-events:none; }'
16936
17270
  + '.aptx-clock-options { display:flex; align-items:center; gap:14px; margin:6px 0 8px; }'
16937
17271
  + '.aptx-clock-options .aptx-clock-deselect { margin-left:auto; height:22px; padding:0 9px; }'
@@ -16974,13 +17308,30 @@ function (root, d3, forester, phyloXml) {
16974
17308
  + '.aptx-panel .' + PROGNAMELINK + ' { background:none; padding:0; margin:0; cursor:pointer;'
16975
17309
  + ' font-family:inherit; font-weight:inherit; letter-spacing:inherit; }'
16976
17310
  + '.aptx-panel .' + PROGNAMELINK + ':hover { text-decoration:underline; }'
17311
+ + '.aptx-doctor-dot { display:inline-block; width:6px; height:6px; border-radius:50%; background:var(--p-muted); vertical-align:middle; }'
17312
+ + '.aptx-doctor-dot[hidden] { display:none; }'
17313
+ + '.aptx-panel .' + PROGNAMELINK + ' .aptx-doctor-dot { margin-left:6px; }'
17314
+ + '.aptx-about .aptx-doctor-dot { margin-right:6px; }'
17315
+ + '.aptx-dialog-body.aptx-doctor { max-height:min(70vh,560px); }'
17316
+ + '.aptx-doctor .aptx-dialog-key { flex-basis:34%; }'
17317
+ + '.aptx-doctor-head { display:flex; align-items:center; gap:10px; margin-bottom:6px; }'
17318
+ + '.aptx-doctor-status { flex:1 1 auto; min-width:0; font-weight:650; }'
17319
+ + '.aptx-doctor-status.aptx-doctor-bad { box-shadow:inset 3px 0 0 var(--p-warn); padding-left:9px; }'
17320
+ + '.aptx-doctor-item { position:relative; padding:2px 0 2px 14px; line-height:1.35; overflow-wrap:anywhere; }'
17321
+ + '.aptx-doctor-item::before { content:""; position:absolute; left:2px; top:0.62em; width:6px; height:6px; border-radius:50%; background:var(--p-faint); }'
17322
+ + '.aptx-doctor-item.aptx-doctor-bad::before { background:var(--p-warn); }'
17323
+ + '.aptx-doctor-item.aptx-doctor-note { color:var(--p-muted); }'
17324
+ + '.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); }'
17325
+ + '.aptx-doctor-detail { display:block; color:var(--p-muted); line-height:1.35; }'
17326
+ + '.aptx-doctor .aptx-dialog-line.aptx-doctor-bad .aptx-doctor-value { color:var(--p-warn); font-weight:600; }'
17327
+ + '.aptx-doctor-copy { flex:none; }'
16977
17328
  + '.aptx-dialog-body.aptx-about { padding-top:13px; }'
16978
17329
  // the label column is narrower here than for node data: these values
16979
17330
  // are URLs, and they should not have to wrap mid-word
16980
17331
  + '.aptx-about .aptx-dialog-key { flex-basis:31%; }'
16981
17332
  + '.aptx-about-head { display:flex; align-items:center; gap:12px; margin-bottom:10px; }'
16982
17333
  + '.aptx-about-logo { flex:none; width:56px; height:56px; }'
16983
- + '.aptx-about-logo svg { width:100%; height:100%; display:block; }'
17334
+ + '.aptx-dialog .aptx-about-logo svg { width:100%; height:100%; display:block; }'
16984
17335
  + '.aptx-about-name { font-size:15px; font-weight:650; letter-spacing:-0.01em; }'
16985
17336
  + '.aptx-about-version { color:var(--p-muted); }'
16986
17337
  + '.aptx-about-blurb { margin:0 0 11px; color:var(--p-muted); }'
@@ -17006,6 +17357,7 @@ function (root, d3, forester, phyloXml) {
17006
17357
  + '.aptx-panel legend { float:none; width:auto; padding:0; margin:0 0 5px; font-size:9px; font-weight:700; letter-spacing:0.09em; text-transform:uppercase; color:var(--p-faint); }'
17007
17358
  + '.aptx-panel label { cursor:pointer; }'
17008
17359
  + '.aptx-panel input[type=checkbox],.aptx-panel input[type=radio] { accent-color:var(--p-accent); width:13px; height:13px; vertical-align:-2px; margin:0 4px 0 0; }'
17360
+ + '.aptx-panel input[type=checkbox]:disabled,.aptx-panel input[type=radio]:disabled { cursor:default; }'
17009
17361
  // checkbox/radio + label as one item (used by the Display Data grid and the inline P/A/C and search-option rows)
17010
17362
  // every item carries an invisible box, so the one that lights up
17011
17363
  // (Auto-hide Labels while it is hiding) does not shift its neighbours
@@ -17093,9 +17445,9 @@ function (root, d3, forester, phyloXml) {
17093
17445
  + '.aptx-panel .aptx-searchnav { align-items:center; gap:4px; margin:2px 0 4px; }'
17094
17446
  + '.aptx-panel .aptx-searchnav span { flex:1 1 auto; text-align:center; font-weight:600; font-size:11px; color:var(--p-ink); }'
17095
17447
  + '.aptx-panel .aptx-searchnav .aptx-gbtn:last-child { margin-right:0; }'
17096
- + '.aptx-found-halo { opacity:' + HALO_REST_OPACITY + '; transform-box:fill-box; transform-origin:center; animation:aptx-halo-pulse 1.3s ease-in-out infinite; }'
17448
+ + 'svg.' + TREE_SVG_CLASS + ' .aptx-found-halo { opacity:' + HALO_REST_OPACITY + '; transform-box:fill-box; transform-origin:center; animation:aptx-halo-pulse 1.3s ease-in-out infinite; }'
17097
17449
  + '@keyframes aptx-halo-pulse { 0%,100% { transform:scale(1); opacity:' + HALO_REST_OPACITY + '; } 50% { transform:scale(2.5); opacity:0.12; } }'
17098
- + '@media (prefers-reduced-motion: reduce) { .aptx-found-halo { animation:none; } }'
17450
+ + '@media (prefers-reduced-motion: reduce) { svg.' + TREE_SVG_CLASS + ' .aptx-found-halo { animation:none; } }'
17099
17451
  + '.aptx-panel .aptx-zoomgrid { display:flex; flex-direction:column; align-items:stretch; }'
17100
17452
  + '.aptx-panel .aptx-zoomgrid > input[type=button] { width:100%; margin-right:0; }'
17101
17453
  + '.aptx-panel .aptx-zoomrow { display:flex; }'
@@ -17255,6 +17607,16 @@ function (root, d3, forester, phyloXml) {
17255
17607
  e.stopPropagation();
17256
17608
  showAboutDialog();
17257
17609
  });
17610
+ // config warnings are recorded before this header exists
17611
+ syncDoctorDot();
17612
+ // ... and a library can arrive after it (see syncDoctorDot)
17613
+ let panel = header.closest('.aptx-panel');
17614
+ if (panel) {
17615
+ panel.addEventListener('pointerenter', syncDoctorDot);
17616
+ }
17617
+ if (document.readyState !== 'complete') {
17618
+ window.addEventListener('load', syncDoctorDot, {once: true});
17619
+ }
17258
17620
 
17259
17621
  let themeBtn = document.createElement('button');
17260
17622
  themeBtn.type = 'button';
@@ -18088,10 +18450,693 @@ function (root, d3, forester, phyloXml) {
18088
18450
  shell.dialog.showModal();
18089
18451
  }
18090
18452
 
18453
+ // ===================== Diagnostics =====================
18454
+ // "Dependencies are checked when used, never at load time" makes the
18455
+ // viewer hard to break and easy to run half-equipped without knowing: on
18456
+ // BV-BRC it ran for weeks with no PDF or PNG export, a retired config key
18457
+ // and a forced visualization, and every one of those was found by reading
18458
+ // the site's source (Christian, 2026-10-09: a "check engine light" and a
18459
+ // doctor behind it). This is the one place that says what is loaded, what
18460
+ // that switches off, and what the launch complained about.
18461
+ //
18462
+ // Two rules decide what is a PROBLEM (lights the dot) and what is only
18463
+ // stated:
18464
+ // * an optional library that is simply absent is a site's choice, and
18465
+ // is stated ("PDF export: off"), never flagged;
18466
+ // * something nobody chooses is a problem: half of a pair (jspdf
18467
+ // without svg2pdf.js), a library from the 2.x days under its old
18468
+ // global, a d3 that is not 7, forester.js from another release, a
18469
+ // phyloxml that no longer writes what this release expects, and every
18470
+ // warning or error the launch printed about its config.
18471
+ // Only d3, jsPDF and forester state a version, so everything else is
18472
+ // judged by what it can do.
18473
+ //
18474
+ // Nothing in here may throw: every check runs in its own try, and a check
18475
+ // that fails says so in its own row.
18476
+
18477
+ // phyloxml states no version, so a small tree goes through it and what
18478
+ // comes back is read for what an OLDER release is known to do. Each of
18479
+ // these is a named fault, and each published release before 1.1.7 shows
18480
+ // at least one (measured on 1.0.2, 1.1.3, 1.1.4, 1.1.5 and 1.1.6):
18481
+ // * it drops the domain architecture, or a <phylogeny>'s own property
18482
+ // (before 1.1.5 / 1.1.3): a saved tree LOSES data;
18483
+ // * it writes a style colour as it came, or the style property before
18484
+ // the node's other properties (before 1.1.7): the saved file differs
18485
+ // from the desktop program's.
18486
+ // Whether the output is byte for byte what 1.1.7 writes is looked at too,
18487
+ // but a difference there alone is only a NOTE. package.json admits any
18488
+ // 1.1.x from 1.1.7 up, a release of this viewer cannot be corrected
18489
+ // after it is published, and a newer phyloxml that lays a file out
18490
+ // differently must not light a dot on every page that has it. (A first
18491
+ // version demanded the bytes and called every difference "an older
18492
+ // copy".)
18493
+ const PHYLOXML_PROBE_IN = '<phyloxml xmlns="http://www.phyloxml.org"><phylogeny rooted="true"><clade>'
18494
+ + '<clade><name>a</name><branch_length>1</branch_length>'
18495
+ + '<sequence><domain_architecture length="9"><domain from="1" to="5" confidence="0.1">D</domain>'
18496
+ + '</domain_architecture></sequence>'
18497
+ + '<property ref="style:font_color" datatype="xsd:token" applies_to="node">#CE1616</property>'
18498
+ + '<property ref="x:y" datatype="xsd:string" applies_to="node">v</property></clade>'
18499
+ + '<clade><name>b</name><branch_length>2</branch_length></clade></clade>'
18500
+ + '<property ref="p:q" datatype="xsd:string" applies_to="phylogeny">w</property></phylogeny></phyloxml>';
18501
+ const PHYLOXML_PROBE_OUT = ['<?xml version="1.0" encoding="UTF-8"?>',
18502
+ '<phyloxml xmlns="http://www.phyloxml.org">',
18503
+ '<phylogeny rooted="true" rerootable="true">',
18504
+ ' <clade>',
18505
+ ' <clade>',
18506
+ ' <name>a</name>',
18507
+ ' <branch_length>1</branch_length>',
18508
+ ' <sequence>',
18509
+ ' <domain_architecture length="9">',
18510
+ ' <domain from="1" to="5" confidence="0.1">D</domain>',
18511
+ ' </domain_architecture>',
18512
+ ' </sequence>',
18513
+ ' <property ref="x:y" datatype="xsd:string" applies_to="node">v</property>',
18514
+ ' <property ref="style:font_color" datatype="xsd:token" applies_to="node">#ce1616</property>',
18515
+ ' </clade>',
18516
+ ' <clade>',
18517
+ ' <name>b</name>',
18518
+ ' <branch_length>2</branch_length>',
18519
+ ' </clade>',
18520
+ ' </clade>',
18521
+ ' <property ref="p:q" datatype="xsd:string" applies_to="phylogeny">w</property>',
18522
+ '</phylogeny>',
18523
+ '</phyloxml>'].join('\n');
18524
+ const PHYLOXML_EXPECTED = '1.1.7';
18525
+ const D3_TESTED_MINOR = 9; // package.json: d3 ^7.9.0
18526
+ const JSPDF_TESTED_MAJOR = 4; // package.json: jspdf ^4.2.1
18527
+
18528
+ // The probe's answer for the library object it was asked about: the
18529
+ // dot asks often, and the answer cannot change.
18530
+ // threw / stage reading ('read') or writing ('write') failed, with the message
18531
+ // lost what a saved tree no longer has
18532
+ // old what is written as releases before 1.1.7 wrote it
18533
+ // same the output is byte for byte 1.1.7's
18534
+ let _phyloXmlProbe = null;
18535
+ function probePhyloXml(px) {
18536
+ if (_phyloXmlProbe && _phyloXmlProbe.lib === px) {
18537
+ return _phyloXmlProbe.result;
18538
+ }
18539
+ let result = {threw: null, stage: null, lost: [], old: [], same: false};
18540
+ let message = function (e) {
18541
+ return String(e && e.message ? e.message : e);
18542
+ };
18543
+ let tree = null;
18544
+ try {
18545
+ tree = px.parse(PHYLOXML_PROBE_IN, {trim: true, normalize: true})[0];
18546
+ } catch (e) {
18547
+ result.threw = message(e);
18548
+ result.stage = 'read';
18549
+ }
18550
+ let written = null;
18551
+ if (!result.threw) {
18552
+ try {
18553
+ written = String(px.toPhyloXML(tree, 9));
18554
+ } catch (e) {
18555
+ result.threw = message(e);
18556
+ result.stage = 'write';
18557
+ }
18558
+ }
18559
+ if (written !== null) {
18560
+ result.same = written === PHYLOXML_PROBE_OUT;
18561
+ if (!/<domain from="1" to="5"[^>]*>D<\/domain>/.test(written)) {
18562
+ result.lost.push('protein domains');
18563
+ }
18564
+ if (written.indexOf('ref="p:q"') < 0) {
18565
+ result.lost.push('a tree\'s own properties');
18566
+ }
18567
+ if (written.indexOf('ref="x:y"') < 0) {
18568
+ result.lost.push('node properties');
18569
+ }
18570
+ let style = written.indexOf('ref="style:font_color"');
18571
+ if (style < 0) {
18572
+ result.lost.push('node styles');
18573
+ } else {
18574
+ if (written.indexOf('>#ce1616<') < 0) {
18575
+ result.old.push('a style colour as it came, not in lower case');
18576
+ }
18577
+ if (written.indexOf('ref="x:y"') > style) {
18578
+ result.old.push('a node\'s style before its other properties');
18579
+ }
18580
+ }
18581
+ }
18582
+ _phyloXmlProbe = {lib: px, result: result};
18583
+ return result;
18584
+ }
18585
+
18586
+ // One library's row. `judge` returns {loaded, version, status, detail};
18587
+ // status is 'ok', 'absent' (optional and not there: stated), 'note' or
18588
+ // 'problem'.
18589
+ function doctorLibrary(name, role, judge) {
18590
+ let row = {name: name, role: role, loaded: false, version: null, status: 'problem', detail: ''};
18591
+ try {
18592
+ let j = judge();
18593
+ row.loaded = !!j.loaded;
18594
+ row.version = j.version || null;
18595
+ row.status = j.status;
18596
+ row.detail = j.detail || '';
18597
+ } catch (e) {
18598
+ row.detail = 'this check failed: ' + (e && e.message ? e.message : e);
18599
+ }
18600
+ return row;
18601
+ }
18602
+
18603
+ function doctorLibraries() {
18604
+ let g = root || {};
18605
+ // without a document there is nothing to launch into: the parsers
18606
+ // work with no d3 at all, so its absence there is not a fault
18607
+ let inBrowser = typeof g.document !== 'undefined';
18608
+ let libs = [];
18609
+
18610
+ libs.push(doctorLibrary('forester.js', 'required', function () {
18611
+ let f = forester || g.forester;
18612
+ if (!f) {
18613
+ return {loaded: false, status: 'problem', detail: 'not loaded: nothing can be read or drawn'};
18614
+ }
18615
+ if (f.VERSION === VERSION) {
18616
+ return {loaded: true, version: f.VERSION, status: 'ok'};
18617
+ }
18618
+ return {loaded: true, version: f.VERSION || null, status: 'problem',
18619
+ detail: (f.VERSION ? 'forester.js is ' + f.VERSION : 'forester.js states no version, so it is an older file')
18620
+ + ', archaeopteryx.js is ' + VERSION + ': the two must come from the same release'};
18621
+ }));
18622
+
18623
+ libs.push(doctorLibrary('d3', 'required', function () {
18624
+ // THE d3 THE VIEWER USES, chosen as requireD3 chooses it: the one
18625
+ // it was handed at load, and the page's global only where that
18626
+ // one is missing or unusable. (A first version looked at the
18627
+ // global first and could report on a d3 nothing was drawn with.)
18628
+ let d = (g.d3 && (!d3 || typeof d3.zoom !== 'function')) ? g.d3 : d3;
18629
+ if (!d) {
18630
+ return inBrowser
18631
+ ? {loaded: false, status: 'problem', detail: 'not loaded: launch() fails without d3 version 7'}
18632
+ : {loaded: false, status: 'absent', detail: 'not loaded: only launch() needs it'};
18633
+ }
18634
+ let usable = typeof d.zoom === 'function' && typeof d.cluster === 'function';
18635
+ let version = typeof d.version === 'string' ? d.version : null;
18636
+ if (version === null) {
18637
+ // d3's ES-module build exports no `version`: a bundler's
18638
+ // `import * as d3`, jsdelivr's +esm, Node's require('d3'). It
18639
+ // is judged by what only d3 7 has -- InternMap arrived with
18640
+ // d3-array 3, which is d3 7.0.0 -- and never called outdated
18641
+ // for not saying its number.
18642
+ if (usable && typeof d.InternMap === 'function') {
18643
+ return {loaded: true, status: 'ok', detail: 'states no version (an ES-module build); has what d3 7 has'};
18644
+ }
18645
+ return {loaded: true, status: inBrowser ? 'problem' : 'absent',
18646
+ detail: inBrowser ? 'states no version and lacks what d3 7 has: Archaeopteryx.js needs d3 version 7'
18647
+ : 'not usable here as d3 version 7: only launch() needs it'};
18648
+ }
18649
+ let parts = version.split('.').map(Number);
18650
+ if (parts[0] !== 7 || !usable) {
18651
+ return {loaded: true, version: version, status: inBrowser ? 'problem' : 'absent',
18652
+ detail: inBrowser ? 'Archaeopteryx.js needs d3 version 7'
18653
+ : 'not usable here as d3 version 7: only launch() needs it'};
18654
+ }
18655
+ if (parts[1] < D3_TESTED_MINOR) {
18656
+ return {loaded: true, version: version, status: 'note',
18657
+ detail: 'older than the 7.' + D3_TESTED_MINOR + ' this release is tested with'};
18658
+ }
18659
+ return {loaded: true, version: version, status: 'ok'};
18660
+ }));
18661
+
18662
+ let px = null;
18663
+ let probe = null;
18664
+ libs.push(doctorLibrary('phyloxml', 'required', function () {
18665
+ px = phyloXml || g.phyloXml;
18666
+ probe = px ? probePhyloXml(px) : null;
18667
+ if (!px) {
18668
+ return {loaded: false, status: 'problem',
18669
+ detail: 'not loaded: phyloXML cannot be read, and the phyloXML download fails'};
18670
+ }
18671
+ if (probe.threw) {
18672
+ // on a page phyloxml takes sax from the global, and a missing
18673
+ // one is the usual reason reading fails; anywhere else, and
18674
+ // for any other failure, the message itself is the finding
18675
+ return {loaded: true, status: 'problem', detail: (probe.stage === 'read' && inBrowser && !g.sax)
18676
+ ? 'cannot read phyloXML: sax.js is not loaded'
18677
+ : (probe.stage === 'read' ? 'reading' : 'writing') + ' a small tree failed: ' + probe.threw};
18678
+ }
18679
+ if (probe.lost.length > 0) {
18680
+ return {loaded: true, status: 'problem', detail: 'an older copy: saving a tree as phyloXML drops '
18681
+ + probe.lost.join(', ') + ' (phyloxml ' + PHYLOXML_EXPECTED + ' keeps them)'};
18682
+ }
18683
+ if (probe.old.length > 0) {
18684
+ return {loaded: true, status: 'problem', detail: 'an older copy: it writes ' + probe.old.join(', and ')
18685
+ + ', so a saved tree differs from the desktop program\'s file (phyloxml ' + PHYLOXML_EXPECTED
18686
+ + ' does not)'};
18687
+ }
18688
+ if (!probe.same) {
18689
+ return {loaded: true, status: 'note', detail: 'writes a small tree differently from phyloxml '
18690
+ + PHYLOXML_EXPECTED + ', losing nothing: a newer release, or an altered copy'};
18691
+ }
18692
+ return {loaded: true, status: 'ok', detail: 'writes phyloXML as ' + PHYLOXML_EXPECTED + ' does'};
18693
+ }));
18694
+ libs.push(doctorLibrary('sax', 'required', function () {
18695
+ // A page hands sax over as a global. Anywhere else (Node, a
18696
+ // bundler) phyloxml gets it by require() when it loads, so a
18697
+ // phyloxml that loaded has it, and a tree it read proves it.
18698
+ if (g.sax || (probe && probe.stage !== 'read')) {
18699
+ return {loaded: true, status: 'ok'};
18700
+ }
18701
+ if (!inBrowser && px) {
18702
+ return {loaded: true, status: 'ok', detail: 'taken by phyloxml when it loaded'};
18703
+ }
18704
+ return {loaded: false, status: 'problem', detail: 'not loaded: phyloxml needs it to read phyloXML'};
18705
+ }));
18706
+
18707
+ libs.push(doctorLibrary('canvg', 'optional', function () {
18708
+ if (g.Canvg && typeof g.Canvg.fromString === 'function') {
18709
+ return {loaded: true, status: 'ok'};
18710
+ }
18711
+ if (g.Canvg) {
18712
+ return {loaded: true, status: 'problem',
18713
+ detail: 'window.Canvg has no fromString: not canvg 4, so PNG export is off'};
18714
+ }
18715
+ if (g.canvg) {
18716
+ return {loaded: true, status: 'problem', detail: 'loaded under its old global "canvg" (canvg 1 to 3);'
18717
+ + ' PNG export needs canvg 4 as window.Canvg, so it is off'};
18718
+ }
18719
+ return {loaded: false, status: 'absent', detail: 'not loaded: PNG export and Copy PNG are off'};
18720
+ }));
18721
+
18722
+ // The pair is read inside each row's own guard: whatever a page has
18723
+ // put under these names, a lookup that throws costs its row and no
18724
+ // other.
18725
+ let pdfPair = function () {
18726
+ let J = g.jspdf && g.jspdf.jsPDF;
18727
+ return {J: J, attached: !!(J && J.API && J.API.svg), svg2pdf: !!g.svg2pdf};
18728
+ };
18729
+ libs.push(doctorLibrary('jspdf', 'optional', function () {
18730
+ let pair = pdfPair();
18731
+ let J = pair.J;
18732
+ let attached = pair.attached;
18733
+ if (J) {
18734
+ let version = typeof J.version === 'string' ? J.version : null;
18735
+ if (!attached && !g.svg2pdf) {
18736
+ return {loaded: true, version: version, status: 'problem',
18737
+ detail: 'loaded without svg2pdf.js: PDF export needs both, so it is off'};
18738
+ }
18739
+ if (version && Number(version.split('.')[0]) !== JSPDF_TESTED_MAJOR) {
18740
+ return {loaded: true, version: version, status: 'note',
18741
+ detail: 'this release is tested with jspdf ' + JSPDF_TESTED_MAJOR};
18742
+ }
18743
+ return {loaded: true, version: version, status: 'ok'};
18744
+ }
18745
+ if (g.jsPDF) {
18746
+ return {loaded: true, status: 'problem', detail: 'loaded under its old global "jsPDF" (jsPDF 1);'
18747
+ + ' PDF export needs jspdf ' + JSPDF_TESTED_MAJOR + ' as window.jspdf, so it is off'};
18748
+ }
18749
+ return {loaded: false, status: 'absent', detail: 'not loaded: PDF export is off'};
18750
+ }));
18751
+ libs.push(doctorLibrary('svg2pdf.js', 'optional', function () {
18752
+ let pair = pdfPair();
18753
+ let J = pair.J;
18754
+ let attached = pair.attached;
18755
+ if (attached) {
18756
+ return {loaded: true, status: 'ok'};
18757
+ }
18758
+ if (g.svg2pdf && J) {
18759
+ return {loaded: true, status: 'problem', detail: 'loaded, but not attached to jspdf (jsPDF.API.svg'
18760
+ + ' is missing): load jspdf first. PDF export is off'};
18761
+ }
18762
+ if (g.svg2pdf) {
18763
+ return {loaded: true, status: 'problem',
18764
+ detail: 'loaded without jspdf: PDF export needs both, so it is off'};
18765
+ }
18766
+ return {loaded: false, status: 'absent',
18767
+ detail: J ? 'not loaded' : 'not loaded: PDF export is off'};
18768
+ }));
18769
+ return libs;
18770
+ }
18771
+
18772
+ // What the user gets or does not get, each with the reason.
18773
+ function doctorFunctions(libs) {
18774
+ let lib = function (name) {
18775
+ return libs.find(function (l) {
18776
+ return l.name === name;
18777
+ });
18778
+ };
18779
+ let inBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
18780
+ let fn = function (name, test, whyNot) {
18781
+ let available = false;
18782
+ let detail = '';
18783
+ try {
18784
+ available = inBrowser && !!test();
18785
+ detail = available ? '' : (inBrowser ? whyNot() : 'needs a browser');
18786
+ } catch (e) {
18787
+ detail = 'this check failed: ' + (e && e.message ? e.message : e);
18788
+ }
18789
+ return {name: name, available: available, detail: detail};
18790
+ };
18791
+ // why a function is off, in the library's name: its row's reason
18792
+ // reads "not loaded: ..." under its own name and needs the subject here
18793
+ let why = function (l) {
18794
+ return l.status === 'absent' ? l.name + ' is not loaded' : l.name + ': ' + l.detail;
18795
+ };
18796
+ let out = [];
18797
+ try {
18798
+ // ON whenever phyloxml reads and writes at all -- which is what
18799
+ // the Download menu's phyloXML entry does, and it is always
18800
+ // offered. An older copy still works: its row above says what a
18801
+ // saved file loses, and this row repeats it without turning off.
18802
+ let px = lib('phyloxml');
18803
+ let works = px.loaded && !/(cannot read phyloXML|a small tree failed|this check failed)/.test(px.detail);
18804
+ out.push({name: 'Read and save phyloXML', available: works,
18805
+ detail: px.status === 'ok' ? '' : (works ? px.detail : why(px))});
18806
+ } catch (e) {
18807
+ out.push({name: 'Read and save phyloXML', available: false,
18808
+ detail: 'this check failed: ' + (e && e.message ? e.message : e)});
18809
+ }
18810
+ out.push(fn('PNG export', pngExportAvailable, function () {
18811
+ return why(lib('canvg'));
18812
+ }));
18813
+ out.push(fn('Copy PNG', pngCopyAvailable, function () {
18814
+ if (!pngExportAvailable()) {
18815
+ return why(lib('canvg'));
18816
+ }
18817
+ return window.isSecureContext ? 'this browser cannot write an image to the clipboard'
18818
+ : 'needs a secure page (https, or localhost)';
18819
+ }));
18820
+ out.push(fn('PDF export', pdfExportAvailable, function () {
18821
+ let pair = [lib('jspdf'), lib('svg2pdf.js')];
18822
+ let bad = pair.find(function (l) {
18823
+ return l.status === 'problem';
18824
+ });
18825
+ if (bad) {
18826
+ return why(bad);
18827
+ }
18828
+ return pair.every(function (l) {
18829
+ return l.status === 'absent';
18830
+ }) ? 'jspdf and svg2pdf.js are not loaded' : why(pair.find(function (l) {
18831
+ return l.status !== 'ok';
18832
+ }) || pair[0]);
18833
+ }));
18834
+ return out;
18835
+ }
18836
+
18837
+ // A config value as one short word or number: enough to see what a site
18838
+ // passed, never the value itself where that could be a tree or a callback.
18839
+ function doctorConfigValue(v) {
18840
+ if (v === null || typeof v === 'boolean' || typeof v === 'number') {
18841
+ return v;
18842
+ }
18843
+ if (typeof v === 'string') {
18844
+ return v.length > 80 ? v.substring(0, 77) + '...' : v;
18845
+ }
18846
+ if (typeof v === 'function') {
18847
+ return '(function)';
18848
+ }
18849
+ if (Array.isArray(v)) {
18850
+ return '(list of ' + v.length + ')';
18851
+ }
18852
+ return '(' + typeof v + ')';
18853
+ }
18854
+
18855
+ // The tree on view, as noted when its launch took the page over
18856
+ // (_onView): null until one has, so a first launch that was rejected
18857
+ // describes no tree. The tip count is the one the launch computed.
18858
+ function doctorLaunch() {
18859
+ if (!_onView) {
18860
+ return null;
18861
+ }
18862
+ let launch = {config: {}, tips: null, tree: _onView.tree, trees: _onView.trees, container: null};
18863
+ try {
18864
+ Object.keys(_onView.config || {}).sort().forEach(function (k) {
18865
+ launch.config[k] = doctorConfigValue(_onView.config[k]);
18866
+ });
18867
+ } catch {
18868
+ launch.config = {};
18869
+ }
18870
+ launch.tips = (typeof _onView.tips === 'number') ? _onView.tips : null;
18871
+ try {
18872
+ launch.container = {width: _onView.container.clientWidth, height: _onView.container.clientHeight};
18873
+ } catch {
18874
+ launch.container = null;
18875
+ }
18876
+ return launch;
18877
+ }
18878
+
18879
+ function doctorText(rep) {
18880
+ let pad = function (text, n) {
18881
+ text = String(text);
18882
+ return text + ' '.repeat(Math.max(1, n - text.length));
18883
+ };
18884
+ let lines = [NAME + ' ' + rep.version + ' diagnostics', ''];
18885
+ lines.push('Status: ' + rep.summary);
18886
+ rep.problems.forEach(function (p) {
18887
+ lines.push(' ! ' + p);
18888
+ });
18889
+ lines.push('', 'Libraries');
18890
+ rep.libraries.forEach(function (l) {
18891
+ lines.push(' ' + pad(l.name, 13) + pad(l.version || (l.loaded ? 'loaded' : 'not loaded'), 12)
18892
+ + pad(l.status === 'absent' ? l.role : l.status, 9) + l.detail);
18893
+ });
18894
+ lines.push('', 'Functions');
18895
+ rep.functions.forEach(function (f) {
18896
+ lines.push(' ' + pad(f.name, 24) + (f.available ? 'on' : 'off') + (f.detail ? ' ' + f.detail : ''));
18897
+ });
18898
+ if (rep.notes.length > 0) {
18899
+ lines.push('', 'Notes');
18900
+ rep.notes.forEach(function (n) {
18901
+ lines.push(' - ' + n);
18902
+ });
18903
+ }
18904
+ lines.push('', 'Launch');
18905
+ if (rep.launch) {
18906
+ lines.push(' config: ' + JSON.stringify(rep.launch.config));
18907
+ if (rep.launch.tips !== null) {
18908
+ lines.push(' tree: ' + rep.launch.tips + ' tips' + (rep.launch.trees > 1
18909
+ ? ' (tree ' + rep.launch.tree + ' of ' + rep.launch.trees + ')' : ''));
18910
+ }
18911
+ if (rep.launch.container) {
18912
+ lines.push(' container: ' + rep.launch.container.width + ' x ' + rep.launch.container.height + ' px');
18913
+ }
18914
+ } else {
18915
+ lines.push(' no tree is on view');
18916
+ }
18917
+ lines.push('', 'Environment');
18918
+ lines.push(' ' + (rep.environment.userAgent || 'no browser'));
18919
+ if (rep.environment.secureContext !== null) {
18920
+ lines.push(' secure page: ' + (rep.environment.secureContext ? 'yes' : 'no'));
18921
+ }
18922
+ return lines.join('\n');
18923
+ }
18924
+
18925
+ // The problems and the notes: the libraries' and what the launch said.
18926
+ // This is all the dot needs, so it is kept apart from the report, which
18927
+ // also works out the functions, the launch section and the text.
18928
+ function doctorFindings() {
18929
+ let libs;
18930
+ try {
18931
+ libs = doctorLibraries();
18932
+ } catch (e) {
18933
+ libs = [{name: 'libraries', role: 'required', loaded: false, version: null, status: 'problem',
18934
+ detail: 'the checks failed: ' + (e && e.message ? e.message : e)}];
18935
+ }
18936
+ let problems = [];
18937
+ let notes = [];
18938
+ libs.forEach(function (l) {
18939
+ if (l.status === 'problem') {
18940
+ problems.push(l.name + ': ' + l.detail);
18941
+ } else if (l.status === 'note') {
18942
+ notes.push(l.name + (l.version ? ' ' + l.version : '') + ': ' + l.detail);
18943
+ }
18944
+ });
18945
+ _diagnostics.forEach(function (d) {
18946
+ (d.level === 'problem' ? problems : notes).push(d.text + (d.count > 1 ? ' (' + d.count + ' times)' : ''));
18947
+ });
18948
+ return {libraries: libs, problems: problems, notes: notes,
18949
+ summary: problems.length === 0 ? 'nothing to report'
18950
+ : problems.length + (problems.length === 1 ? ' thing' : ' things') + ' to check'};
18951
+ }
18952
+
18953
+ function doctorReport() {
18954
+ let found = doctorFindings();
18955
+ let libs = found.libraries;
18956
+ let problems = found.problems;
18957
+ let notes = found.notes;
18958
+ let functions;
18959
+ try {
18960
+ functions = doctorFunctions(libs);
18961
+ } catch {
18962
+ functions = [];
18963
+ }
18964
+ let launch = null;
18965
+ try {
18966
+ launch = doctorLaunch();
18967
+ } catch {
18968
+ launch = null;
18969
+ }
18970
+ let nav = (typeof navigator !== 'undefined') ? navigator : null;
18971
+ let rep = {
18972
+ program: NAME,
18973
+ version: VERSION,
18974
+ ok: problems.length === 0,
18975
+ summary: found.summary,
18976
+ problems: problems,
18977
+ notes: notes,
18978
+ libraries: libs,
18979
+ functions: functions,
18980
+ launch: launch,
18981
+ environment: {
18982
+ userAgent: nav && nav.userAgent ? nav.userAgent : null,
18983
+ secureContext: (typeof window !== 'undefined' && typeof window.isSecureContext === 'boolean')
18984
+ ? window.isSecureContext : null
18985
+ }
18986
+ };
18987
+ try {
18988
+ rep.text = doctorText(rep);
18989
+ } catch (e) {
18990
+ rep.text = NAME + ' ' + VERSION + ' diagnostics: the report could not be written ('
18991
+ + (e && e.message ? e.message : e) + ')';
18992
+ }
18993
+ return rep;
18994
+ }
18995
+
18996
+ /**
18997
+ * The state of the program on this page: which libraries are loaded (and
18998
+ * their versions, where a library states one), which functions that
18999
+ * switches off, and what the last launch() warned about. Works before a
19000
+ * launch and after a failed one, and in Node. Never throws.
19001
+ *
19002
+ * @returns {{program: string, version: string, ok: boolean, summary: string,
19003
+ * problems: string[], notes: string[], libraries: Object[], functions: Object[],
19004
+ * launch: Object|null, environment: Object, text: string}} `ok` is false when
19005
+ * something is wrong that nobody chooses (see README "Diagnostics");
19006
+ * `text` is the whole report as plain text, for pasting into an issue.
19007
+ */
19008
+ archaeopteryx.doctor = function () {
19009
+ let rep = doctorReport();
19010
+ syncDoctorDot(); // whoever asks sees a dot that agrees with the answer
19011
+ return rep;
19012
+ };
19013
+
19014
+ // The "check engine light": a small dot after the program name in the
19015
+ // panel header, there only while the report has a problem. Neutral, not
19016
+ // a warning colour: the people who see it are mostly the site's users,
19017
+ // who can do nothing about it, and it is for the one who can.
19018
+ //
19019
+ // What a page has loaded can change under a viewer that is already up (a
19020
+ // library arriving late, a script replacing a global) and nothing
19021
+ // announces it, so the dot is brought up to date whenever it could be
19022
+ // looked at or asked about: when a problem is recorded, when the header
19023
+ // is built, when the page finishes loading, when the pointer enters the
19024
+ // panel, when the About box opens and when doctor() is called.
19025
+ function syncDoctorDot() {
19026
+ try {
19027
+ let host = _onView && _onView.container;
19028
+ if (!host || typeof host.querySelector !== 'function') {
19029
+ return;
19030
+ }
19031
+ let button = host.querySelector('.aptx-panel .' + PROGNAMELINK);
19032
+ let dot = button && button.querySelector('.aptx-doctor-dot');
19033
+ if (!dot) {
19034
+ return;
19035
+ }
19036
+ let found = doctorFindings();
19037
+ let ok = found.problems.length === 0;
19038
+ dot.hidden = ok;
19039
+ button.title = 'About ' + NAME + (ok ? '' : ': ' + found.summary);
19040
+ } catch {
19041
+ // a light that cannot be lit must not take the viewer with it
19042
+ }
19043
+ }
19044
+
19045
+ function showDoctorDialog() {
19046
+ let rep = doctorReport();
19047
+ let shell = makeDialogShell(DOCTOR_DIALOG, 'Diagnostics', 500);
19048
+ shell.body.classList.add('aptx-doctor');
19049
+
19050
+ // The summary and Copy report share the first line: the report is
19051
+ // longer than a small window, and the one button must not be the
19052
+ // thing scrolled out of sight.
19053
+ let head = document.createElement('div');
19054
+ head.className = 'aptx-doctor-head';
19055
+ let status = document.createElement('div');
19056
+ status.className = 'aptx-doctor-status' + (rep.ok ? '' : ' aptx-doctor-bad');
19057
+ status.textContent = NAME + ' ' + rep.version + ': ' + rep.summary;
19058
+ let copy = document.createElement('button');
19059
+ copy.type = 'button';
19060
+ copy.className = 'aptx-reps-button aptx-doctor-copy';
19061
+ copy.textContent = 'Copy report';
19062
+ copy.title = 'copy this report as text, with the launch configuration and the browser added,'
19063
+ + ' to paste into an issue or an e-mail';
19064
+ copy.addEventListener('click', function () {
19065
+ copyText(rep.text, shell.dialog).then(function (ok) {
19066
+ flashButton(copy, ok ? 'Copied \u2713' : 'Copy failed');
19067
+ });
19068
+ });
19069
+ head.appendChild(status);
19070
+ head.appendChild(copy);
19071
+ shell.body.appendChild(head);
19072
+
19073
+ let list = function (items, cls) {
19074
+ items.forEach(function (text) {
19075
+ let item = document.createElement('div');
19076
+ item.className = 'aptx-doctor-item ' + cls;
19077
+ item.textContent = text; // may quote a config value: never markup
19078
+ shell.body.appendChild(item);
19079
+ });
19080
+ };
19081
+ let section = function (title) {
19082
+ let h = document.createElement('div');
19083
+ h.className = 'aptx-doctor-section';
19084
+ h.textContent = title;
19085
+ shell.body.appendChild(h);
19086
+ };
19087
+ let row = function (key, value, detail, bad) {
19088
+ let line = document.createElement('div');
19089
+ line.className = 'aptx-dialog-line' + (bad ? ' aptx-doctor-bad' : '');
19090
+ let k = document.createElement('span');
19091
+ k.className = 'aptx-dialog-key';
19092
+ k.textContent = key;
19093
+ let v = document.createElement('span');
19094
+ v.className = 'aptx-dialog-val';
19095
+ let main = document.createElement('span');
19096
+ main.className = 'aptx-doctor-value';
19097
+ main.textContent = value;
19098
+ v.appendChild(main);
19099
+ if (detail) {
19100
+ let d = document.createElement('span');
19101
+ d.className = 'aptx-doctor-detail';
19102
+ d.textContent = detail;
19103
+ v.appendChild(d);
19104
+ }
19105
+ line.appendChild(k);
19106
+ line.appendChild(v);
19107
+ shell.body.appendChild(line);
19108
+ };
19109
+ list(rep.problems, 'aptx-doctor-bad');
19110
+
19111
+ section('Libraries');
19112
+ rep.libraries.forEach(function (l) {
19113
+ // "not loaded" is the value: a reason that opens with the same
19114
+ // two words and a colon does not say them again. (Only that
19115
+ // exact opening: "loaded, but not attached..." keeps its start.)
19116
+ let value = l.version || (l.loaded ? 'loaded' : 'not loaded');
19117
+ let detail = l.detail === value ? ''
19118
+ : (l.detail.indexOf('not loaded: ') === 0 && value === 'not loaded' ? l.detail.substring(12) : l.detail);
19119
+ row(l.name, value, detail, l.status === 'problem');
19120
+ });
19121
+ section('Functions');
19122
+ rep.functions.forEach(function (f) {
19123
+ row(f.name, f.available ? 'on' : 'off', f.detail, false);
19124
+ });
19125
+ if (rep.notes.length > 0) {
19126
+ section('Notes');
19127
+ list(rep.notes, 'aptx-doctor-note');
19128
+ }
19129
+
19130
+ shell.dialog.showModal();
19131
+ }
19132
+
18091
19133
  // The About box behind the panel's title. Everything here is fixed text and
18092
19134
  // fixed URLs -- no tree data reaches it -- so the links are built as real
18093
- // elements rather than markup.
19135
+ // elements rather than markup. (The Diagnostics row's text is the doctor's
19136
+ // own summary: a count, never a value.)
18094
19137
  function showAboutDialog() {
19138
+ let rep = doctorReport();
19139
+ syncDoctorDot();
18095
19140
  let shell = makeDialogShell(ABOUT_DIALOG, 'About', 380);
18096
19141
  shell.body.classList.add('aptx-about');
18097
19142
 
@@ -18099,7 +19144,7 @@ function (root, d3, forester, phyloXml) {
18099
19144
  head.className = 'aptx-about-head';
18100
19145
  let logo = document.createElement('div');
18101
19146
  logo.className = 'aptx-about-logo';
18102
- logo.innerHTML = ARCHAEOPTERYX_LOGO_SVG;
19147
+ logo.innerHTML = paintInline(ARCHAEOPTERYX_LOGO_SVG);
18103
19148
  head.appendChild(logo);
18104
19149
 
18105
19150
  let titles = document.createElement('div');
@@ -18123,8 +19168,9 @@ function (root, d3, forester, phyloXml) {
18123
19168
  ['Desktop version', DESKTOP_WEBSITE, 'cmzmasek.github.io/archaeopteryx'],
18124
19169
  ['Source code', SOURCE_WEBSITE, 'github.com/cmzmasek/archaeopteryx-js'],
18125
19170
  ['License', LICENSE_WEBSITE, LICENSE_NAME],
18126
- ['Keyboard', null, 'shortcuts (' + (IS_MAC ? '\u2318 /' : 'Ctrl+/') + ')'],
18127
- ['Control panel', null, 'what each control does']].forEach(function (row) {
19171
+ ['Keyboard', null, 'shortcuts (' + (IS_MAC ? '\u2318 /' : 'Ctrl+/') + ')', showShortcutsDialog],
19172
+ ['Control panel', null, 'what each control does', showCheatSheetDialog],
19173
+ ['Diagnostics', null, rep.summary, showDoctorDialog]].forEach(function (row) {
18128
19174
  let line = document.createElement('div');
18129
19175
  line.className = 'aptx-dialog-line';
18130
19176
  let key = document.createElement('span');
@@ -18138,9 +19184,9 @@ function (root, d3, forester, phyloXml) {
18138
19184
  a.target = '_blank';
18139
19185
  a.rel = 'noopener noreferrer';
18140
19186
  } 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;
19187
+ // the rows with no URL open another dialog in place of this
19188
+ // one: the key list, the control-panel sheet, the diagnostics
19189
+ let opener = row[3];
18144
19190
  a.href = '#';
18145
19191
  a.addEventListener('click', function (e) {
18146
19192
  e.preventDefault();
@@ -18149,6 +19195,14 @@ function (root, d3, forester, phyloXml) {
18149
19195
  });
18150
19196
  }
18151
19197
  a.textContent = row[2];
19198
+ if (row[0] === 'Diagnostics') {
19199
+ a.className = 'aptx-doctor-link';
19200
+ if (!rep.ok) {
19201
+ let dot = document.createElement('span');
19202
+ dot.className = 'aptx-doctor-dot';
19203
+ val.appendChild(dot);
19204
+ }
19205
+ }
18152
19206
  val.appendChild(a);
18153
19207
  line.appendChild(key);
18154
19208
  line.appendChild(val);
@@ -18720,7 +19774,8 @@ function (root, d3, forester, phyloXml) {
18720
19774
  // by this, and a version is a value -- it belongs in the About box
18721
19775
  // it opens, not in a row that says what the button is
18722
19776
  h = h.concat('<button type="button" class="' + PROGNAMELINK + '" aria-label="' + NAME
18723
- + '" title="About ' + NAME + '">' + NAME + ' ' + VERSION + '</button>');
19777
+ + '" title="About ' + NAME + '">' + NAME + ' ' + VERSION
19778
+ + '<span class="aptx-doctor-dot" hidden></span></button>');
18724
19779
  h = h.concat('</div>');
18725
19780
  return h;
18726
19781
  }
@@ -19397,7 +20452,7 @@ function (root, d3, forester, phyloXml) {
19397
20452
  // 2x, 4x and 8x, and the setting is now the one it starts on
19398
20453
  // (Christian, 2026-09-28) -- said, not done silently
19399
20454
  if (_pngScale !== _settings.pngExportScale) {
19400
- console.warn(WARNING + ': pngExportScale ' + _settings.pngExportScale + ' is not one of '
20455
+ say('problem', 'warn', WARNING + ': pngExportScale ' + _settings.pngExportScale + ' is not one of '
19401
20456
  + DOWNLOAD_PNG_SCALES.join(', ') + ': the PNG starts at ' + _pngScale + 'x');
19402
20457
  }
19403
20458
  setRadioButtonValue(_downloadFullSize ? DOWNLOAD_FULL_SIZE_BUTTON : DOWNLOAD_AS_SHOWN_BUTTON, true);
@@ -19424,7 +20479,7 @@ function (root, d3, forester, phyloXml) {
19424
20479
  .attr('height', '100%')
19425
20480
  .style('opacity', 1)
19426
20481
  .attr('class', BASE_BACKGROUND)
19427
- .attr('fill', _state.backgroundColorDefault);
20482
+ .style('fill', _state.backgroundColorDefault);
19428
20483
  }
19429
20484
 
19430
20485
 
@@ -19630,6 +20685,19 @@ function (root, d3, forester, phyloXml) {
19630
20685
  return _baseSvg ? _baseSvg.node() : null;
19631
20686
  }
19632
20687
 
20688
+ // The tree svg's size, said twice. The attributes are what an export and
20689
+ // the layout read; but an attribute gives way to any rule of the page the
20690
+ // viewer is drawn in: under "svg { width: 24px; height: 24px }", a page
20691
+ // whose only svgs were icons, the tree was 24 px square. (A rule that says
20692
+ // "height: auto" changes nothing: the attributes still give the svg its
20693
+ // own size.) The inline style does not give way.
20694
+ function sizeTreeSvg(svg, w, h) {
20695
+ svg.attr('width', w)
20696
+ .attr('height', h)
20697
+ .style('width', w + 'px')
20698
+ .style('height', h + 'px');
20699
+ }
20700
+
19633
20701
  // An export is always light, whatever the screen is set to: a dark PNG or
19634
20702
  // SVG is wrong on paper, in a slide and in a paper figure, and it is the
19635
20703
  // file that outlives the session. Only the four theme colours are swapped,
@@ -19673,6 +20741,15 @@ function (root, d3, forester, phyloXml) {
19673
20741
  }
19674
20742
 
19675
20743
  function cleanExportCopy(copy) {
20744
+ // What the live svg carries only for the page it is drawn in (its
20745
+ // class, its size said a second time): a file has no page, and an
20746
+ // inline size would hold a full-size drawing to the window's.
20747
+ copy.classList.remove(TREE_SVG_CLASS);
20748
+ if (!copy.getAttribute('class')) {
20749
+ copy.removeAttribute('class');
20750
+ }
20751
+ copy.style.removeProperty('width');
20752
+ copy.style.removeProperty('height');
19676
20753
  // Serialize a COPY with the overview taken out of it: the overview is
19677
20754
  // an on-screen navigation aid, not part of the tree, and working on a
19678
20755
  // copy leaves the live display untouched.
@@ -19689,7 +20766,57 @@ function (root, d3, forester, phyloXml) {
19689
20766
  if (glow) {
19690
20767
  glow.remove();
19691
20768
  }
19692
- return copy;
20769
+ return paintAsAttributes(copy);
20770
+ }
20771
+
20772
+ // The opposite of paintInline, for a file: a shape's paint, inline styles
20773
+ // on the page, is attributes again (FILE_PAINT). Where an element says a
20774
+ // thing both ways, the style is what painted it and has the last word.
20775
+ function paintAsAttributes(svg) {
20776
+ [svg].concat(Array.prototype.slice.call(svg.querySelectorAll('[style]'))).forEach(function (el) {
20777
+ let style = el.style;
20778
+ if (!style) {
20779
+ return;
20780
+ }
20781
+ FILE_PAINT.forEach(function (name) {
20782
+ let value = style.getPropertyValue(name);
20783
+ if (value !== '') {
20784
+ // (the browser writes url("#id"); an attribute has no quotes there)
20785
+ el.setAttribute(name, value.replace(/^url\("([^"]*)"\)$/, 'url($1)'));
20786
+ style.removeProperty(name);
20787
+ }
20788
+ });
20789
+ if (style.length === 0) {
20790
+ el.removeAttribute('style');
20791
+ }
20792
+ });
20793
+ // A colour with an alpha is the colour and, in the paint's own
20794
+ // opacity, the alpha times what the shape has of it already, stated
20795
+ // or handed down; a paint that is no paint at all is "none". Only on
20796
+ // a shape: an opacity stated on a group would reach the shapes in it
20797
+ // that have a colour of their own.
20798
+ Array.prototype.slice.call(svg.querySelectorAll('[fill], [stroke], [stop-color]')).forEach(function (el) {
20799
+ let holdsShapes = Array.prototype.some.call(el.children, function (child) {
20800
+ return child.localName !== 'title' && child.localName !== 'desc';
20801
+ });
20802
+ Object.keys(FILE_ALPHA).forEach(function (paint) {
20803
+ let c = el.hasAttribute(paint) ? d3.color(el.getAttribute(paint)) : null;
20804
+ if (!c || !(c.opacity < 1) || holdsShapes) {
20805
+ return;
20806
+ }
20807
+ let opacity = FILE_ALPHA[paint];
20808
+ if (c.opacity === 0 && paint !== 'stop-color') {
20809
+ el.setAttribute(paint, 'none');
20810
+ return;
20811
+ }
20812
+ // (a gradient's stop hands nothing down, and is handed nothing)
20813
+ let from = paint === 'stop-color' ? (el.hasAttribute(opacity) ? el : null) : el.closest('[' + opacity + ']');
20814
+ let has = from ? parseFloat(from.getAttribute(opacity)) : 1;
20815
+ el.setAttribute(paint, c.copy({opacity: 1}).formatRgb());
20816
+ el.setAttribute(opacity, String(+(c.opacity * (isNaN(has) ? 1 : has)).toFixed(4)));
20817
+ });
20818
+ });
20819
+ return svg;
19693
20820
  }
19694
20821
 
19695
20822
  // The same element of the copy as `live` is of the svg: children are
@@ -20139,7 +21266,7 @@ function (root, d3, forester, phyloXml) {
20139
21266
 
20140
21267
  function downloadAsPdf(graphic) {
20141
21268
  if (!pdfExportAvailable()) {
20142
- console.error(ERROR + 'PDF export needs the optional jspdf and svg2pdf.js libraries on the page');
21269
+ say('note', 'error', ERROR + 'PDF export needs the optional jspdf and svg2pdf.js libraries on the page');
20143
21270
  return;
20144
21271
  }
20145
21272
  let el = new DOMParser().parseFromString(graphic.text, 'image/svg+xml').documentElement;
@@ -20188,7 +21315,7 @@ function (root, d3, forester, phyloXml) {
20188
21315
  holder.remove();
20189
21316
  }, function (err) {
20190
21317
  holder.remove();
20191
- console.error(ERROR + 'PDF export failed: ' + err);
21318
+ say('note', 'error', ERROR + 'PDF export failed: ' + err);
20192
21319
  });
20193
21320
  }
20194
21321
 
@@ -20234,7 +21361,7 @@ function (root, d3, forester, phyloXml) {
20234
21361
  renderPng(graphic).then(function (blob) {
20235
21362
  saveAs(blob, downloadFileName(PNG_SUFFIX));
20236
21363
  }, function (err) {
20237
- console.error(ERROR + 'PNG export failed: ' + err);
21364
+ say('note', 'error', ERROR + 'PNG export failed: ' + err);
20238
21365
  });
20239
21366
  }
20240
21367
 
@@ -20252,7 +21379,7 @@ function (root, d3, forester, phyloXml) {
20252
21379
  return Promise.reject(new Error('this browser cannot paint a canvas of that size'));
20253
21380
  }
20254
21381
  if (target.scale < _pngScale) {
20255
- console.warn(WARNING + ': PNG exported at ' + target.scale.toFixed(2) + 'x rather than ' + _pngScale
21382
+ say('note', 'warn', WARNING + ': PNG exported at ' + target.scale.toFixed(2) + 'x rather than ' + _pngScale
20256
21383
  + 'x: the largest image this browser can paint');
20257
21384
  }
20258
21385
  let canvas = target.canvas;
@@ -20313,7 +21440,7 @@ function (root, d3, forester, phyloXml) {
20313
21440
  written.then(function () {
20314
21441
  flashButton(btn, 'Copied ✓');
20315
21442
  }, function (err) {
20316
- console.error(ERROR + 'copying the PNG failed: ' + err);
21443
+ say('note', 'error', ERROR + 'copying the PNG failed: ' + err);
20317
21444
  flashButton(btn, 'Copy failed');
20318
21445
  });
20319
21446
  }
@@ -20480,7 +21607,17 @@ function (root, d3, forester, phyloXml) {
20480
21607
  return undefined;
20481
21608
  }
20482
21609
 
20483
- archaeopteryx.launchArchaeopteryx = function (container, fileName, data, config) {
21610
+ archaeopteryx.launchArchaeopteryx = function () {
21611
+ try {
21612
+ return parseAndLaunch.apply(null, arguments);
21613
+ } catch (e) {
21614
+ // its own failures -- a fifth argument, a file that does not
21615
+ // parse -- happen before launch() and its shell are reached
21616
+ recordLaunchFailure(e);
21617
+ throw e;
21618
+ }
21619
+ };
21620
+ function parseAndLaunch(container, fileName, data, config) {
20484
21621
  if (arguments.length > 4) {
20485
21622
  throw new Error(ERROR + 'launchArchaeopteryx() takes exactly (container, fileName,'
20486
21623
  + ' data, config); the old trailing arguments were removed. The separate settings'
@@ -20508,7 +21645,7 @@ function (root, d3, forester, phyloXml) {
20508
21645
  // added nothing but a prefix, and swallowing them left the caller with
20509
21646
  // a blank page and no way to find out why.
20510
21647
  return archaeopteryx.launch(container, trees, config);
20511
- };
21648
+ }
20512
21649
 
20513
21650
 
20514
21651
  // --------------------------------------------------------------