archaeopteryx 3.6.1 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -5
- package/archaeopteryx.d.ts +6 -1
- package/archaeopteryx.js +654 -21
- package/forester.js +1191 -58
- package/package.json +1 -1
package/archaeopteryx.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
// v 3.
|
|
23
|
+
// v 3.8.0
|
|
24
24
|
// 2026-09-10
|
|
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.
|
|
106
|
+
const VERSION = '3.8.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';
|
|
@@ -277,6 +277,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
277
277
|
const PANEL_STYLE_ID = 'aptx-panel-styles';
|
|
278
278
|
// How wide a legend row is treated as, for grabbing it with the mouse.
|
|
279
279
|
const PANEL_WIDTH = 214; // fixed control-panel width; shared by the .aptx-panel CSS and leftPanelClearance() so the two can't drift
|
|
280
|
+
const PANEL_WIDTH_COMPACT = 196; // the same, at panelDensity 'compact'; panelWidth() picks between them
|
|
280
281
|
const SLIDER_CLASS = 'aptx-slider';
|
|
281
282
|
const SLIDER_STEP = 0.5;
|
|
282
283
|
const SPECIATION_COLOR = '#009E73';
|
|
@@ -380,6 +381,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
380
381
|
const INTERNAL_LABEL_CB = 'intl_cb';
|
|
381
382
|
const LABEL_COLOR_SELECT_MENU = 'lcs_menu';
|
|
382
383
|
const MIDPOINT_ROOT_BUTTON = 'midpointr_b';
|
|
384
|
+
const REPRESENTATIVES_BUTTON = 'reps_b';
|
|
385
|
+
const REPRESENTATIVES_DIALOG = 'reps_dialog';
|
|
383
386
|
const MAD_CITATION = 'Tria F, Landan G, Dagan T. Phylogenetic rooting using minimal ancestor deviation. Nature Ecology and Evolution. 2017;1:0193';
|
|
384
387
|
const UNCOLLAPSE_ALL_BUTTON = 'uncollapse_all_b';
|
|
385
388
|
const TREE_PREV_BUTTON = 'tree_prev_b';
|
|
@@ -515,6 +518,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
515
518
|
let _trees = []; // every tree of the launch (one, or all a file held); _treeData is _trees[_treeIndex]
|
|
516
519
|
let _treeIndex = 0;
|
|
517
520
|
let _launchConfig = null; // the config as launch() received it, for showTree()
|
|
521
|
+
let _treeViews = null; // tree object -> the view it was last left in (see viewForTree)
|
|
522
|
+
let _panelSections = null; // panel section name -> true when folded; the user's furniture, remembered (see loadPanelSections)
|
|
523
|
+
let _panelOpenOrder = new Map(); // section name -> when it was opened, so the fit rule folds the oldest first
|
|
524
|
+
let _panelOpenSeq = 0;
|
|
518
525
|
let _viewOps = {order: null, root: null}; // the tree operations a shared view replays: ladderize direction, midpoint rooting
|
|
519
526
|
let _lastViewEncoded; // the view last reported to onViewChange; undefined = not settled yet
|
|
520
527
|
let _viewNotifyPending = false;
|
|
@@ -4862,6 +4869,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
4862
4869
|
'nhExportWriteConfidences',
|
|
4863
4870
|
'nodeLabels',
|
|
4864
4871
|
'onViewChange',
|
|
4872
|
+
'panelDensity',
|
|
4865
4873
|
'pngExportScale',
|
|
4866
4874
|
'rootOffset',
|
|
4867
4875
|
'supportDotMinimum',
|
|
@@ -4937,8 +4945,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
4937
4945
|
// the visualizations legend need this, and neither may hard-code it: the
|
|
4938
4946
|
// panel's own geometry is the only honest source, or the two drift apart --
|
|
4939
4947
|
// which is exactly how the root ended up drawn behind the panel once.
|
|
4948
|
+
// The panel's width for the density in force. The CSS and this function
|
|
4949
|
+
// must agree, or the tree's left margin and the panel drift apart.
|
|
4950
|
+
function panelWidth() {
|
|
4951
|
+
return (_settings && _settings.panelDensity === 'compact') ? PANEL_WIDTH_COMPACT : PANEL_WIDTH;
|
|
4952
|
+
}
|
|
4953
|
+
|
|
4940
4954
|
function leftPanelClearance() {
|
|
4941
|
-
return CONTROLS_0_LEFT_DEFAULT +
|
|
4955
|
+
return CONTROLS_0_LEFT_DEFAULT + panelWidth() + ROOT_CLEARANCE;
|
|
4942
4956
|
}
|
|
4943
4957
|
|
|
4944
4958
|
// The only validation initializeState() performs. Hoisted so launch() can
|
|
@@ -5281,6 +5295,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
5281
5295
|
if (_settings.collapseControlPanel === undefined) {
|
|
5282
5296
|
_settings.collapseControlPanel = false;
|
|
5283
5297
|
}
|
|
5298
|
+
if (_settings.panelDensity === undefined) {
|
|
5299
|
+
_settings.panelDensity = 'comfortable';
|
|
5300
|
+
} else if (_settings.panelDensity !== 'comfortable' && _settings.panelDensity !== 'compact') {
|
|
5301
|
+
throw new Error(ERROR + '"panelDensity" must be "comfortable" or "compact"');
|
|
5302
|
+
}
|
|
5284
5303
|
if (_settings.enableDynamicSizing === undefined) {
|
|
5285
5304
|
_settings.enableDynamicSizing = true;
|
|
5286
5305
|
}
|
|
@@ -5547,6 +5566,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
5547
5566
|
_trees = [];
|
|
5548
5567
|
_treeIndex = 0;
|
|
5549
5568
|
_launchConfig = null;
|
|
5569
|
+
_treeViews = null;
|
|
5550
5570
|
_lastViewEncoded = undefined;
|
|
5551
5571
|
_viewOps = {order: null, root: null};
|
|
5552
5572
|
_basicTreeProperties = null;
|
|
@@ -5630,9 +5650,18 @@ function (root, d3, forester, phyloXml) {
|
|
|
5630
5650
|
if (config && config.onViewChange !== undefined && config.onViewChange !== null && typeof config.onViewChange !== 'function') {
|
|
5631
5651
|
throw new Error(ERROR + '"onViewChange" must be a function (state, encoded) or null');
|
|
5632
5652
|
}
|
|
5653
|
+
// checked up here with the others so an embedder's typo fails at the
|
|
5654
|
+
// call rather than three frames deep, and so Node can test it
|
|
5655
|
+
if (config && config.panelDensity !== undefined
|
|
5656
|
+
&& config.panelDensity !== 'comfortable' && config.panelDensity !== 'compact') {
|
|
5657
|
+
throw new Error(ERROR + '"panelDensity" must be "comfortable" or "compact"');
|
|
5658
|
+
}
|
|
5633
5659
|
// a view names the tree it was made on; a fresh launch reports no
|
|
5634
5660
|
// view until one changes (settleViewNotification)
|
|
5635
5661
|
_lastViewEncoded = undefined;
|
|
5662
|
+
// each tree's own view is remembered for the life of this launch,
|
|
5663
|
+
// and only for it: a new launch starts everything fresh
|
|
5664
|
+
_treeViews = new Map();
|
|
5636
5665
|
let view = config && config.view;
|
|
5637
5666
|
let start = (view && Number.isInteger(view.tree) && view.tree > 0 && view.tree < trees.length) ? view.tree : 0;
|
|
5638
5667
|
return launchInto(container, trees, start, config);
|
|
@@ -5816,10 +5845,91 @@ function (root, d3, forester, phyloXml) {
|
|
|
5816
5845
|
return makeViewerHandle();
|
|
5817
5846
|
}
|
|
5818
5847
|
|
|
5848
|
+
// ================= each tree keeps its own view =====================
|
|
5849
|
+
// In a file holding several trees, every tree is its own workspace: you
|
|
5850
|
+
// leave one as you had it -- layout, display type, labels, colours,
|
|
5851
|
+
// sizes, tracks, both searches, the clade you switched to and the ones
|
|
5852
|
+
// you collapsed -- and it comes back that way (Christian, 2026-09-16).
|
|
5853
|
+
// This replaces "every tree opens fresh", and with it the rule that a
|
|
5854
|
+
// switch cleared collapsing (Christian, 2026-09-14, made when a switch
|
|
5855
|
+
// discarded everything else too).
|
|
5856
|
+
//
|
|
5857
|
+
// The memory IS the view state, so a remembered tree opens down exactly
|
|
5858
|
+
// the path a shared link opens: launchInto with cfg.view. Nothing here
|
|
5859
|
+
// knows about layouts or checkboxes; whatever getViewState covers is
|
|
5860
|
+
// remembered, and what it leaves out (zoom and pan, the legend's
|
|
5861
|
+
// position, the selection) stays out here too.
|
|
5862
|
+
//
|
|
5863
|
+
// A tree you have NOT opened yet is not handed the whole view -- what a
|
|
5864
|
+
// tree shows and colours by is read from its own content, and imposing
|
|
5865
|
+
// another tree's answer would waste that. It inherits the geometry
|
|
5866
|
+
// alone: the settings that say nothing about what is in the tree. So
|
|
5867
|
+
// stepping through ten trees of one file in the circular layout keeps
|
|
5868
|
+
// the circular layout, while each tree still labels itself its own way.
|
|
5869
|
+
const VIEW_CARRY_KEYS = ['layout', 'display', 'font', 'node', 'branch', 'rotation', 'horizontalLabels'];
|
|
5870
|
+
|
|
5871
|
+
// The tree on screen, as it stands, filed under the tree OBJECT: indices
|
|
5872
|
+
// shift when a tree is appended (representative tips), objects do not.
|
|
5873
|
+
// The `tree` key goes: it names the index this view came from, and
|
|
5874
|
+
// applying it back would read as a switch to itself.
|
|
5875
|
+
function rememberTreeView() {
|
|
5876
|
+
if (!_treeViews || !_trees[_treeIndex] || !_root_const) {
|
|
5877
|
+
return;
|
|
5878
|
+
}
|
|
5879
|
+
let state = getViewState();
|
|
5880
|
+
delete state.tree;
|
|
5881
|
+
_treeViews.set(_trees[_treeIndex], state);
|
|
5882
|
+
}
|
|
5883
|
+
|
|
5884
|
+
// What to open a tree with: the view it was left in, or -- a tree not
|
|
5885
|
+
// opened yet -- the current tree's geometry alone.
|
|
5886
|
+
function viewForTree(tree) {
|
|
5887
|
+
let remembered = _treeViews && _treeViews.get(tree);
|
|
5888
|
+
if (remembered) {
|
|
5889
|
+
return remembered;
|
|
5890
|
+
}
|
|
5891
|
+
let current = _root_const ? getViewState() : {};
|
|
5892
|
+
let carried = {};
|
|
5893
|
+
VIEW_CARRY_KEYS.forEach(function (k) {
|
|
5894
|
+
if (current[k] !== undefined) {
|
|
5895
|
+
carried[k] = current[k];
|
|
5896
|
+
}
|
|
5897
|
+
});
|
|
5898
|
+
return carried;
|
|
5899
|
+
}
|
|
5900
|
+
|
|
5901
|
+
// The one way from one tree of the list to another: the tree being left
|
|
5902
|
+
// is remembered first, and the launch config's own view (a shared link's)
|
|
5903
|
+
// never leaks onto the next tree -- _launchConfig is put back afterwards,
|
|
5904
|
+
// so every later switch still opens each tree its own way. `view` is for
|
|
5905
|
+
// a caller that already has one (a hash naming another tree).
|
|
5906
|
+
function switchToTree(index, view) {
|
|
5907
|
+
rememberTreeView();
|
|
5908
|
+
// The tree being left goes back to the way it was PARSED: its collapsed
|
|
5909
|
+
// clades live in the view just recorded, not on its nodes. Leaving the
|
|
5910
|
+
// flags on the object would change the COLOURS it comes back with --
|
|
5911
|
+
// the palette is dealt once per launch, over the values summarizeView
|
|
5912
|
+
// can see, and a tree whose nodes still carried collapse would deal it
|
|
5913
|
+
// over the reduced set and hand every value a different colour. A
|
|
5914
|
+
// shared link into a collapsed view never does that: it opens a freshly
|
|
5915
|
+
// parsed tree, deals over all of it, and collapses afterwards. A tree
|
|
5916
|
+
// coming back must not either. (Measured on the flavivirus demo: the
|
|
5917
|
+
// legend came back re-coloured until this line went in.)
|
|
5918
|
+
if (_trees[_treeIndex]) {
|
|
5919
|
+
clearCollapsedFlags(_trees[_treeIndex]);
|
|
5920
|
+
}
|
|
5921
|
+
let launchConfig = _launchConfig;
|
|
5922
|
+
let cfg = Object.assign({}, launchConfig || {});
|
|
5923
|
+
cfg.view = view || viewForTree(_trees[index]);
|
|
5924
|
+
let handle = launchInto(_container, _trees, index, cfg);
|
|
5925
|
+
_launchConfig = launchConfig;
|
|
5926
|
+
return handle;
|
|
5927
|
+
}
|
|
5928
|
+
|
|
5819
5929
|
// Shows another tree of the launched list in the same container under
|
|
5820
|
-
// the same config
|
|
5821
|
-
//
|
|
5822
|
-
//
|
|
5930
|
+
// the same config: as you left it, or -- never opened -- on its own
|
|
5931
|
+
// presets in the current geometry. The panel's picker and previous /
|
|
5932
|
+
// next buttons come here, as does the handle's showTree().
|
|
5823
5933
|
function showTree(index) {
|
|
5824
5934
|
if (!_container) {
|
|
5825
5935
|
throw new Error(ERROR + 'showTree(): nothing is launched');
|
|
@@ -5830,11 +5940,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
5830
5940
|
if (index === _treeIndex) {
|
|
5831
5941
|
return makeViewerHandle();
|
|
5832
5942
|
}
|
|
5833
|
-
//
|
|
5834
|
-
//
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
return
|
|
5943
|
+
// Neither tree's collapsed flags are carried on the nodes: the tree
|
|
5944
|
+
// being left has them recorded in its view and then cleared
|
|
5945
|
+
// (switchToTree), and the one arriving has applyViewState clear and
|
|
5946
|
+
// re-apply its own -- a view is always passed, so that always runs.
|
|
5947
|
+
return switchToTree(index, null);
|
|
5838
5948
|
}
|
|
5839
5949
|
|
|
5840
5950
|
archaeopteryx.parsePhyloXML = function (data) {
|
|
@@ -7637,9 +7747,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
7637
7747
|
return;
|
|
7638
7748
|
}
|
|
7639
7749
|
if (!initial && Number.isInteger(s.tree) && s.tree !== _treeIndex && s.tree >= 0 && s.tree < _trees.length) {
|
|
7640
|
-
|
|
7641
|
-
cfg.view = s;
|
|
7642
|
-
launchInto(_container, _trees, s.tree, cfg);
|
|
7750
|
+
switchToTree(s.tree, s); // the view given names another tree: it wins over what that tree remembers
|
|
7643
7751
|
return;
|
|
7644
7752
|
}
|
|
7645
7753
|
if ((s.root === 'midpoint' || s.root === 'mad') && _viewOps.root !== s.root
|
|
@@ -7791,6 +7899,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
7791
7899
|
setValue(idx === 0 ? SEARCH_VALUE2_0 : SEARCH_VALUE2_1, want.value2 ? String(want.value2) : '');
|
|
7792
7900
|
updateSearchValue2Visibility(idx);
|
|
7793
7901
|
updateSearchAutocomplete(idx);
|
|
7902
|
+
if (want.value) {
|
|
7903
|
+
openPanelSection('Search'); // a view carrying a search shows it
|
|
7904
|
+
}
|
|
7794
7905
|
if (idx === 1 && want.value) {
|
|
7795
7906
|
showSearchB();
|
|
7796
7907
|
}
|
|
@@ -8113,6 +8224,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
8113
8224
|
}
|
|
8114
8225
|
|
|
8115
8226
|
function focusSearch() {
|
|
8227
|
+
openPanelSection('Search'); // jumping to a box inside a folded section has to open it
|
|
8116
8228
|
let f = byId(SEARCH_FIELD_0);
|
|
8117
8229
|
if (f) {
|
|
8118
8230
|
f.focus();
|
|
@@ -8812,6 +8924,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
8812
8924
|
// Search B is folded away until wanted: the '+ Search B' link (which
|
|
8813
8925
|
// focuses it) or a view that carries a second search.
|
|
8814
8926
|
function showSearchB() {
|
|
8927
|
+
openPanelSection('Search');
|
|
8815
8928
|
let wrap = byId(SEARCH_B_WRAP);
|
|
8816
8929
|
if (wrap) {
|
|
8817
8930
|
wrap.style.display = '';
|
|
@@ -9007,7 +9120,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
9007
9120
|
|
|
9008
9121
|
let sc = info.type === 'calendar' ? -corr : corr;
|
|
9009
9122
|
|
|
9010
|
-
// ---- HPD age bars (internal) + fossil
|
|
9123
|
+
// ---- HPD age bars (internal) + tip bars: fossil ranges, or sampling dates ----
|
|
9011
9124
|
forEachDisplayed(function (d) {
|
|
9012
9125
|
if (!d.date || typeof d.date.minimum !== 'number' || typeof d.date.maximum !== 'number'
|
|
9013
9126
|
|| d.y === undefined) {
|
|
@@ -9026,10 +9139,28 @@ function (root, d3, forester, phyloXml) {
|
|
|
9026
9139
|
let left = Math.min(xa, xb);
|
|
9027
9140
|
let w = Math.max(1, Math.abs(xb - xa));
|
|
9028
9141
|
let y = d.x;
|
|
9142
|
+
// What a TIP's interval means is the axis's to say. On geologic
|
|
9143
|
+
// time it is a fossil's observed range, FAD to LAD, and is drawn
|
|
9144
|
+
// as one below. On CALENDAR time it is the uncertainty of a
|
|
9145
|
+
// sampling date -- a virus sample dated only to its month or year
|
|
9146
|
+
// -- which is the same kind of thing as an internal node's age
|
|
9147
|
+
// interval and is drawn the same way, slimmer, and only when it
|
|
9148
|
+
// HAS a width: a tip dated to the day states {d,d}, and a capped
|
|
9149
|
+
// tick on every such tip is what made the readers throw tip
|
|
9150
|
+
// intervals away until 2026-09-17 (Christian; the desktop alike).
|
|
9151
|
+
let sampledTip = !d.children && info.type === 'calendar';
|
|
9152
|
+
if (sampledTip && !(max > min)) {
|
|
9153
|
+
return;
|
|
9154
|
+
}
|
|
9029
9155
|
if (d.children) {
|
|
9030
9156
|
g.append('rect').attr('x', left).attr('y', y - 3.5)
|
|
9031
9157
|
.attr('width', w).attr('height', 7)
|
|
9032
9158
|
.attr('fill', HPD_BAR_COLOR);
|
|
9159
|
+
} else if (sampledTip) {
|
|
9160
|
+
g.append('rect').attr('class', 'aptx-sampling-bar')
|
|
9161
|
+
.attr('x', left).attr('y', y - 2.5)
|
|
9162
|
+
.attr('width', w).attr('height', 5)
|
|
9163
|
+
.attr('fill', HPD_BAR_COLOR);
|
|
9033
9164
|
} else {
|
|
9034
9165
|
g.append('rect').attr('x', left).attr('y', y - 2.5)
|
|
9035
9166
|
.attr('width', w).attr('height', 5)
|
|
@@ -9439,6 +9570,296 @@ function (root, d3, forester, phyloXml) {
|
|
|
9439
9570
|
}
|
|
9440
9571
|
}
|
|
9441
9572
|
|
|
9573
|
+
// ---- representative tips ------------------------------------------------
|
|
9574
|
+
//
|
|
9575
|
+
// The desktop's Tools > Select Representative Tips, from the tool row: a
|
|
9576
|
+
// dialog takes a distance cutoff or a target count, the representative to
|
|
9577
|
+
// keep and whether the selected and found tips stay; the tips kept show
|
|
9578
|
+
// as search A's hits, and can be cut out into a new tree in the tree
|
|
9579
|
+
// picker (the desktop's new tab). The choosing is
|
|
9580
|
+
// forester.selectRepresentativeTips, held to the desktop's own results by
|
|
9581
|
+
// test/fixtures/rep-contract.tsv.
|
|
9582
|
+
|
|
9583
|
+
// the dialog's inputs, remembered while the page is open (the desktop
|
|
9584
|
+
// remembers them for its session)
|
|
9585
|
+
let _repsInputs = {byCutoff: true, cutoff: '0.05', target: '100', pickIndex: 0};
|
|
9586
|
+
|
|
9587
|
+
// what the viewer writes onto a tree's nodes, left off a tree cut out of it
|
|
9588
|
+
const VIEW_NODE_FIELDS = ['viewId', 'collapsed', 'x', 'y', 'x0', 'y0', 'id', 'hide', 'hasVis', 'distToRoot',
|
|
9589
|
+
'ux', 'uy', 'uangle', '_style', '_suppDot', '_extLabelText', '_eventText', '_confText', '_blText'];
|
|
9590
|
+
|
|
9591
|
+
// the whole tree's tip count, counted again only when the tree changes
|
|
9592
|
+
// (every change makes new _basicTreeProperties)
|
|
9593
|
+
let _repsTipCount = {properties: null, count: 0};
|
|
9594
|
+
|
|
9595
|
+
function wholeTreeTipCount() {
|
|
9596
|
+
if (_repsTipCount.properties !== _basicTreeProperties) {
|
|
9597
|
+
_repsTipCount = {
|
|
9598
|
+
properties: _basicTreeProperties,
|
|
9599
|
+
count: _root_const ? forester.getAllExternalNodes(_root_const).length : 0
|
|
9600
|
+
};
|
|
9601
|
+
}
|
|
9602
|
+
return _repsTipCount.count;
|
|
9603
|
+
}
|
|
9604
|
+
|
|
9605
|
+
// why representative tips cannot be selected now, or null
|
|
9606
|
+
function representativesBlockedReason() {
|
|
9607
|
+
if (_in_subtree) {
|
|
9608
|
+
return 'return to the whole tree to select representative tips';
|
|
9609
|
+
}
|
|
9610
|
+
if (!_root_const || wholeTreeTipCount() < 3) {
|
|
9611
|
+
return 'the tree needs at least three tips to select representatives';
|
|
9612
|
+
}
|
|
9613
|
+
return null;
|
|
9614
|
+
}
|
|
9615
|
+
|
|
9616
|
+
function repsElement(parent, tag, className, text) {
|
|
9617
|
+
let e = document.createElement(tag);
|
|
9618
|
+
if (className) {
|
|
9619
|
+
e.className = className;
|
|
9620
|
+
}
|
|
9621
|
+
if (text !== undefined) {
|
|
9622
|
+
e.textContent = text;
|
|
9623
|
+
}
|
|
9624
|
+
parent.appendChild(e);
|
|
9625
|
+
return e;
|
|
9626
|
+
}
|
|
9627
|
+
|
|
9628
|
+
function repsButton(parent, label, primary, action) {
|
|
9629
|
+
let b = repsElement(parent, 'button', 'aptx-reps-button' + (primary ? ' aptx-reps-primary' : ''), label);
|
|
9630
|
+
b.type = 'button';
|
|
9631
|
+
b.addEventListener('click', action);
|
|
9632
|
+
return b;
|
|
9633
|
+
}
|
|
9634
|
+
|
|
9635
|
+
// Java's Double.parseDouble and Integer.parseInt, which read the desktop's
|
|
9636
|
+
// fields: null for what they refuse
|
|
9637
|
+
function javaParseDouble(s) {
|
|
9638
|
+
let m = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)[fFdD]?$/.exec(s);
|
|
9639
|
+
if (m) {
|
|
9640
|
+
return Number(m[1]);
|
|
9641
|
+
}
|
|
9642
|
+
return /^[+-]?(?:Infinity|NaN)$/.test(s) ? Number(s) : null;
|
|
9643
|
+
}
|
|
9644
|
+
|
|
9645
|
+
function javaParseInt(s) {
|
|
9646
|
+
if (!/^[+-]?\d+$/.test(s)) {
|
|
9647
|
+
return null;
|
|
9648
|
+
}
|
|
9649
|
+
let v = Number(s);
|
|
9650
|
+
return (v < -2147483648 || v > 2147483647) ? null : v;
|
|
9651
|
+
}
|
|
9652
|
+
|
|
9653
|
+
function representativesButtonPressed() {
|
|
9654
|
+
if (representativesBlockedReason()) {
|
|
9655
|
+
return;
|
|
9656
|
+
}
|
|
9657
|
+
// the selected and found tips can be kept; taken now, before the
|
|
9658
|
+
// result replaces search A's hits
|
|
9659
|
+
let protectable = [];
|
|
9660
|
+
let seen = new Set();
|
|
9661
|
+
[_selectedNodes, _foundNodes0, _foundNodes1].forEach(function (nodes) {
|
|
9662
|
+
(nodes || []).forEach(function (n) {
|
|
9663
|
+
if ((!n.children || n.children.length === 0) && !seen.has(n)) {
|
|
9664
|
+
seen.add(n);
|
|
9665
|
+
protectable.push(n);
|
|
9666
|
+
}
|
|
9667
|
+
});
|
|
9668
|
+
});
|
|
9669
|
+
showRepresentativesDialog(_treeData, forester.hasUsableBranchLengths(_treeData), protectable);
|
|
9670
|
+
}
|
|
9671
|
+
|
|
9672
|
+
// The desktop's input dialog, its texts and checks included; a distance
|
|
9673
|
+
// cutoff needs branch lengths.
|
|
9674
|
+
function showRepresentativesDialog(phy, hasLengths, protectable) {
|
|
9675
|
+
let shell = makeDialogShell(REPRESENTATIVES_DIALOG, 'Select representative tips', 330);
|
|
9676
|
+
let body = shell.body;
|
|
9677
|
+
body.classList.add('aptx-reps');
|
|
9678
|
+
let startByCutoff = hasLengths && _repsInputs.byCutoff;
|
|
9679
|
+
|
|
9680
|
+
repsElement(body, 'div', 'aptx-reps-lead', 'Select one representative per group of similar tips:');
|
|
9681
|
+
let choice = function (type, label, checked, disabled) {
|
|
9682
|
+
let row = repsElement(body, 'label', 'aptx-reps-choice' + (disabled ? ' aptx-reps-disabled' : ''));
|
|
9683
|
+
let input = repsElement(row, 'input');
|
|
9684
|
+
input.type = type;
|
|
9685
|
+
input.name = REPRESENTATIVES_DIALOG + '_' + type;
|
|
9686
|
+
input.checked = checked;
|
|
9687
|
+
input.disabled = disabled;
|
|
9688
|
+
repsElement(row, 'span', null, label);
|
|
9689
|
+
return input;
|
|
9690
|
+
};
|
|
9691
|
+
let byCutoff = choice('radio', 'By distance cutoff', startByCutoff, !hasLengths);
|
|
9692
|
+
let byTarget = choice('radio', 'By target number of representatives', !startByCutoff, false);
|
|
9693
|
+
if (!hasLengths) {
|
|
9694
|
+
repsElement(body, 'div', 'aptx-reps-note',
|
|
9695
|
+
'This tree has no branch lengths, so only "target number" is available.');
|
|
9696
|
+
}
|
|
9697
|
+
|
|
9698
|
+
repsElement(body, 'div', 'aptx-dialog-head', 'Value');
|
|
9699
|
+
let value = repsElement(body, 'input', 'aptx-reps-input');
|
|
9700
|
+
value.type = 'text';
|
|
9701
|
+
value.spellcheck = false;
|
|
9702
|
+
value.autocomplete = 'off';
|
|
9703
|
+
value.value = startByCutoff ? _repsInputs.cutoff : _repsInputs.target;
|
|
9704
|
+
let hint = repsElement(body, 'div', 'aptx-reps-note');
|
|
9705
|
+
|
|
9706
|
+
repsElement(body, 'div', 'aptx-dialog-head', 'Representative');
|
|
9707
|
+
let pick = repsElement(body, 'select', 'aptx-reps-input');
|
|
9708
|
+
['Most central (medoid)', 'Most divergent (longest branch)'].forEach(function (label) {
|
|
9709
|
+
repsElement(pick, 'option', null, label);
|
|
9710
|
+
});
|
|
9711
|
+
pick.selectedIndex = _repsInputs.pickIndex;
|
|
9712
|
+
|
|
9713
|
+
let n = protectable.length;
|
|
9714
|
+
let protect = choice('checkbox', n > 0
|
|
9715
|
+
? 'Keep the ' + n + ' selected/found ' + (n === 1 ? 'tip' : 'tips') + ' (never drop them)'
|
|
9716
|
+
: 'Keep selected/found tips (none are selected)', n > 0, n === 0);
|
|
9717
|
+
protect.parentNode.classList.add('aptx-reps-protect');
|
|
9718
|
+
|
|
9719
|
+
let error = repsElement(body, 'div', 'aptx-reps-error');
|
|
9720
|
+
let sync = function () {
|
|
9721
|
+
hint.textContent = byCutoff.checked
|
|
9722
|
+
? 'Maximum distance between any two tips kept in the same group.'
|
|
9723
|
+
: 'Approximate number of representative tips to keep.';
|
|
9724
|
+
error.textContent = '';
|
|
9725
|
+
};
|
|
9726
|
+
byCutoff.addEventListener('change', function () {
|
|
9727
|
+
value.value = _repsInputs.cutoff;
|
|
9728
|
+
sync();
|
|
9729
|
+
});
|
|
9730
|
+
byTarget.addEventListener('change', function () {
|
|
9731
|
+
value.value = _repsInputs.target;
|
|
9732
|
+
sync();
|
|
9733
|
+
});
|
|
9734
|
+
sync();
|
|
9735
|
+
|
|
9736
|
+
let submit = function () {
|
|
9737
|
+
let text = value.value.trim();
|
|
9738
|
+
let cutoff = 0;
|
|
9739
|
+
let target = 0;
|
|
9740
|
+
if (byCutoff.checked) {
|
|
9741
|
+
cutoff = javaParseDouble(text);
|
|
9742
|
+
if (cutoff === null) {
|
|
9743
|
+
error.textContent = 'Please enter a numeric distance cutoff (for example 0.05).';
|
|
9744
|
+
return;
|
|
9745
|
+
}
|
|
9746
|
+
if (!isFinite(cutoff) || cutoff <= 0) {
|
|
9747
|
+
error.textContent = 'The distance cutoff must be a positive number.';
|
|
9748
|
+
return;
|
|
9749
|
+
}
|
|
9750
|
+
} else {
|
|
9751
|
+
target = javaParseInt(text);
|
|
9752
|
+
if (target === null) {
|
|
9753
|
+
error.textContent = 'Please enter a whole number of representatives (for example 100).';
|
|
9754
|
+
return;
|
|
9755
|
+
}
|
|
9756
|
+
if (target < 1) {
|
|
9757
|
+
error.textContent = 'The target number of representatives must be at least 1.';
|
|
9758
|
+
return;
|
|
9759
|
+
}
|
|
9760
|
+
}
|
|
9761
|
+
// a tree without branch lengths forced the target: that is not a preference
|
|
9762
|
+
if (hasLengths) {
|
|
9763
|
+
_repsInputs.byCutoff = byCutoff.checked;
|
|
9764
|
+
}
|
|
9765
|
+
if (byCutoff.checked) {
|
|
9766
|
+
_repsInputs.cutoff = text;
|
|
9767
|
+
} else {
|
|
9768
|
+
_repsInputs.target = text;
|
|
9769
|
+
}
|
|
9770
|
+
_repsInputs.pickIndex = pick.selectedIndex;
|
|
9771
|
+
shell.dialog.close();
|
|
9772
|
+
|
|
9773
|
+
let options = {
|
|
9774
|
+
pick: pick.selectedIndex === 1 ? forester.REPRESENTATIVE_LONGEST_BRANCH : forester.REPRESENTATIVE_MEDOID,
|
|
9775
|
+
protectedTips: protect.checked ? protectable : []
|
|
9776
|
+
};
|
|
9777
|
+
if (byCutoff.checked) {
|
|
9778
|
+
options.cutoff = cutoff;
|
|
9779
|
+
} else {
|
|
9780
|
+
options.target = target;
|
|
9781
|
+
}
|
|
9782
|
+
let result = forester.selectRepresentativeTips(phy, options);
|
|
9783
|
+
showRepresentativesAsHits(result);
|
|
9784
|
+
showRepresentativesResult(phy, result, byCutoff.checked, cutoff, target, options.pick);
|
|
9785
|
+
};
|
|
9786
|
+
value.addEventListener('keydown', function (event) {
|
|
9787
|
+
if (event.key === 'Enter') {
|
|
9788
|
+
event.preventDefault();
|
|
9789
|
+
submit();
|
|
9790
|
+
}
|
|
9791
|
+
});
|
|
9792
|
+
|
|
9793
|
+
let actions = repsElement(body, 'div', 'aptx-reps-actions');
|
|
9794
|
+
repsButton(actions, 'Cancel', false, function () {
|
|
9795
|
+
shell.dialog.close();
|
|
9796
|
+
});
|
|
9797
|
+
repsButton(actions, 'Select', true, submit);
|
|
9798
|
+
shell.dialog.showModal();
|
|
9799
|
+
value.focus();
|
|
9800
|
+
value.select();
|
|
9801
|
+
}
|
|
9802
|
+
|
|
9803
|
+
// The tips kept, as search A's hits -- the desktop shows them as its
|
|
9804
|
+
// found nodes: A's colour, the hit count, the step-through. The next
|
|
9805
|
+
// search replaces them, and resetting search A clears them.
|
|
9806
|
+
function showRepresentativesAsHits(result) {
|
|
9807
|
+
openPanelSection('Search'); // the tips kept are search A's hits: its navigator has to be visible
|
|
9808
|
+
_foundNodes0 = new Set(result.keptTips);
|
|
9809
|
+
_searchBox0Empty = false;
|
|
9810
|
+
_searchHitIndex = -1;
|
|
9811
|
+
scheduleUpdate(null, 0, true);
|
|
9812
|
+
updateSearchHitNavigation();
|
|
9813
|
+
}
|
|
9814
|
+
|
|
9815
|
+
// The desktop's summary, and its offer of a new tree of the tips kept.
|
|
9816
|
+
function showRepresentativesResult(phy, result, byCutoff, cutoff, target, pick) {
|
|
9817
|
+
let shell = makeDialogShell(REPRESENTATIVES_DIALOG, 'Representative tips selected', 360);
|
|
9818
|
+
shell.body.classList.add('aptx-reps');
|
|
9819
|
+
result.summary.split('\n').forEach(function (line) {
|
|
9820
|
+
repsElement(shell.body, 'div', 'aptx-reps-line', line);
|
|
9821
|
+
});
|
|
9822
|
+
let n = result.keptCount;
|
|
9823
|
+
repsElement(shell.body, 'div', 'aptx-reps-question',
|
|
9824
|
+
'Create a new tree containing only these ' + n + (n === 1 ? ' tip?' : ' tips?'));
|
|
9825
|
+
let actions = repsElement(shell.body, 'div', 'aptx-reps-actions');
|
|
9826
|
+
repsButton(actions, 'Close', false, function () {
|
|
9827
|
+
shell.dialog.close();
|
|
9828
|
+
});
|
|
9829
|
+
let create = repsButton(actions, 'Create tree', true, function () {
|
|
9830
|
+
shell.dialog.close();
|
|
9831
|
+
openRepresentativeTree(phy, result, byCutoff, cutoff, target, pick);
|
|
9832
|
+
});
|
|
9833
|
+
shell.dialog.showModal();
|
|
9834
|
+
create.focus();
|
|
9835
|
+
}
|
|
9836
|
+
|
|
9837
|
+
// The tips kept, cut out into a new tree at the end of the tree picker,
|
|
9838
|
+
// which opens at once: named <parent>_Nreps, its description saying how
|
|
9839
|
+
// it was made, drawn as this tree is (phylogram, aligned or cladogram),
|
|
9840
|
+
// as the desktop's derived tab. This tree is left as it is.
|
|
9841
|
+
function openRepresentativeTree(phy, result, byCutoff, cutoff, target, pick) {
|
|
9842
|
+
let copy = forester.copyTreeKeepingTips(phy, result.keptTips);
|
|
9843
|
+
forester.preOrderTraversalAll(copy, function (n) {
|
|
9844
|
+
VIEW_NODE_FIELDS.forEach(function (f) {
|
|
9845
|
+
delete n[f];
|
|
9846
|
+
});
|
|
9847
|
+
});
|
|
9848
|
+
forester.addParents(copy);
|
|
9849
|
+
let parentName = (typeof phy.name === 'string' && phy.name.length > 0) ? phy.name : null;
|
|
9850
|
+
copy.name = forester.representativeTreeName(parentName, result.keptCount);
|
|
9851
|
+
let provenance = forester.representativeTreeDescription(byCutoff, cutoff, target, pick, result.keptCount,
|
|
9852
|
+
forester.stripShortExtension(parentName), result.tipCount);
|
|
9853
|
+
copy.description = copy.description ? copy.description + ' ' + provenance : provenance;
|
|
9854
|
+
|
|
9855
|
+
_trees.push(copy);
|
|
9856
|
+
// a tree nobody has opened: switchToTree gives it this tree's
|
|
9857
|
+
// geometry, which is what "drawn as the tree was" amounts to --
|
|
9858
|
+
// the display type (phylogram, aligned or cladogram) and the
|
|
9859
|
+
// layout. The tree it came from is remembered as it stands.
|
|
9860
|
+
switchToTree(_trees.length - 1, null);
|
|
9861
|
+
}
|
|
9862
|
+
|
|
9442
9863
|
function escPressed() {
|
|
9443
9864
|
if (_in_subtree) {
|
|
9444
9865
|
_root = _root_const;
|
|
@@ -10501,6 +10922,120 @@ function (root, d3, forester, phyloXml) {
|
|
|
10501
10922
|
}
|
|
10502
10923
|
}
|
|
10503
10924
|
|
|
10925
|
+
// ============ the panel's sections: what is open, and what fits ========
|
|
10926
|
+
// Three rules, deliberately separate, for hosts with little room to give
|
|
10927
|
+
// (BV-BRC embeds the viewer in a page that already has its own chrome):
|
|
10928
|
+
//
|
|
10929
|
+
// - DEFAULTS. A tree arrives with the sections that DESCRIBE it open --
|
|
10930
|
+
// what it can be coloured by, what it shows -- plus Search, which is
|
|
10931
|
+
// reached for constantly (Christian, 2026-09-16). Folded are the ones
|
|
10932
|
+
// that are adjustments for later: zoom, sizes, the domain controls.
|
|
10933
|
+
// Measured on the 13-property BV-BRC demo in a 700px window: 766px of
|
|
10934
|
+
// panel content becomes 621px, with nothing behind a scroll.
|
|
10935
|
+
// - MEMORY. What the user opens and closes is theirs, and it outlives
|
|
10936
|
+
// the tree: kept for the page and in localStorage, like the light/dark
|
|
10937
|
+
// choice. It is NOT part of a tree's view -- the panel is the user's
|
|
10938
|
+
// furniture, not a property of the tree -- which is why it is not in
|
|
10939
|
+
// getViewState, and why a tree switch no longer reopens everything.
|
|
10940
|
+
// - FIT. Opening a section in a short window folds the section opened
|
|
10941
|
+
// longest ago, and ONLY while the panel would not otherwise fit. On a
|
|
10942
|
+
// tall screen it never fires. It is bounded by PIXELS, not by a count
|
|
10943
|
+
// of open sections: three open sections measure anywhere from 215px to
|
|
10944
|
+
// 562px, so a count would not have bounded the height at all.
|
|
10945
|
+
const PANEL_SECTIONS_KEY = 'aptx-panel-sections';
|
|
10946
|
+
const PANEL_SECTIONS_CLOSED_BY_DEFAULT = ['Zoom', 'Sizes', 'Domain Architectures'];
|
|
10947
|
+
|
|
10948
|
+
function loadPanelSections() {
|
|
10949
|
+
if (_panelSections) {
|
|
10950
|
+
return _panelSections;
|
|
10951
|
+
}
|
|
10952
|
+
_panelSections = {};
|
|
10953
|
+
try {
|
|
10954
|
+
let saved = JSON.parse(localStorage.getItem(PANEL_SECTIONS_KEY));
|
|
10955
|
+
if (saved && typeof saved === 'object') {
|
|
10956
|
+
Object.keys(saved).forEach(function (k) {
|
|
10957
|
+
if (typeof saved[k] === 'boolean') {
|
|
10958
|
+
_panelSections[k] = saved[k];
|
|
10959
|
+
}
|
|
10960
|
+
});
|
|
10961
|
+
}
|
|
10962
|
+
} catch {
|
|
10963
|
+
// no storage (private mode), or junk written by something else:
|
|
10964
|
+
// the defaults are a complete answer on their own
|
|
10965
|
+
}
|
|
10966
|
+
return _panelSections;
|
|
10967
|
+
}
|
|
10968
|
+
|
|
10969
|
+
function savePanelSections() {
|
|
10970
|
+
try {
|
|
10971
|
+
localStorage.setItem(PANEL_SECTIONS_KEY, JSON.stringify(_panelSections || {}));
|
|
10972
|
+
} catch {
|
|
10973
|
+
// the choice just won't survive a reload
|
|
10974
|
+
}
|
|
10975
|
+
}
|
|
10976
|
+
|
|
10977
|
+
// Every collapsible section of a panel, in the order they are drawn.
|
|
10978
|
+
function panelSections(panel) {
|
|
10979
|
+
let out = [];
|
|
10980
|
+
panel.querySelectorAll('fieldset').forEach(function (f) {
|
|
10981
|
+
let lg = f.querySelector(':scope > legend.aptx-legend-toggle');
|
|
10982
|
+
if (lg) {
|
|
10983
|
+
out.push({name: lg.textContent.trim(), fieldset: f});
|
|
10984
|
+
}
|
|
10985
|
+
});
|
|
10986
|
+
return out;
|
|
10987
|
+
}
|
|
10988
|
+
|
|
10989
|
+
function sectionFoldedInitially(name) {
|
|
10990
|
+
let remembered = loadPanelSections()[name];
|
|
10991
|
+
return (typeof remembered === 'boolean')
|
|
10992
|
+
? remembered : PANEL_SECTIONS_CLOSED_BY_DEFAULT.indexOf(name) >= 0;
|
|
10993
|
+
}
|
|
10994
|
+
|
|
10995
|
+
// By how many pixels the panel's body overflows the room it has.
|
|
10996
|
+
function panelOverflow(panel) {
|
|
10997
|
+
let body = panel.querySelector(':scope > .aptx-body');
|
|
10998
|
+
return body ? (body.scrollHeight - body.clientHeight) : 0;
|
|
10999
|
+
}
|
|
11000
|
+
|
|
11001
|
+
// Keeps the panel to one screen: while it overflows, fold the section
|
|
11002
|
+
// opened longest ago. Never the one just opened -- that is the one the
|
|
11003
|
+
// user asked for -- and never the last one standing.
|
|
11004
|
+
function fitPanelSections(panel, justOpened) {
|
|
11005
|
+
let guard = 0;
|
|
11006
|
+
while (panelOverflow(panel) > 0 && ++guard < 20) {
|
|
11007
|
+
let open = panelSections(panel).filter(function (s) {
|
|
11008
|
+
return !s.fieldset.classList.contains('aptx-collapsed') && s.name !== justOpened;
|
|
11009
|
+
});
|
|
11010
|
+
if (open.length === 0) {
|
|
11011
|
+
return;
|
|
11012
|
+
}
|
|
11013
|
+
open.sort(function (a, b) {
|
|
11014
|
+
return (_panelOpenOrder.get(a.name) || 0) - (_panelOpenOrder.get(b.name) || 0);
|
|
11015
|
+
});
|
|
11016
|
+
open[0].fieldset.classList.add('aptx-collapsed');
|
|
11017
|
+
loadPanelSections()[open[0].name] = true;
|
|
11018
|
+
}
|
|
11019
|
+
}
|
|
11020
|
+
|
|
11021
|
+
// Unfolds a section because something the user just did has to be SEEN
|
|
11022
|
+
// inside it: the search box they jumped to, the hits a tool marked.
|
|
11023
|
+
function openPanelSection(name) {
|
|
11024
|
+
let panel = _container && _container.querySelector(':scope > .aptx-panel');
|
|
11025
|
+
if (!panel) {
|
|
11026
|
+
return;
|
|
11027
|
+
}
|
|
11028
|
+
panelSections(panel).forEach(function (s) {
|
|
11029
|
+
if (s.name === name && s.fieldset.classList.contains('aptx-collapsed')) {
|
|
11030
|
+
s.fieldset.classList.remove('aptx-collapsed');
|
|
11031
|
+
loadPanelSections()[name] = false;
|
|
11032
|
+
_panelOpenOrder.set(name, ++_panelOpenSeq);
|
|
11033
|
+
savePanelSections();
|
|
11034
|
+
fitPanelSections(panel, name);
|
|
11035
|
+
}
|
|
11036
|
+
});
|
|
11037
|
+
}
|
|
11038
|
+
|
|
10504
11039
|
// ===================== Control-panel glyphs =====================
|
|
10505
11040
|
// Vector glyphs ported from the desktop Archaeopteryx control panel (its
|
|
10506
11041
|
// DisplayTypeIcon / ControlButtonIcon / LayoutIcon / ThemeToggleIcon /
|
|
@@ -10770,6 +11305,16 @@ function (root, d3, forester, phyloXml) {
|
|
|
10770
11305
|
+ glyphDot(50, 50, 11);
|
|
10771
11306
|
}
|
|
10772
11307
|
|
|
11308
|
+
// Representative tips: two clades of two tips, one tip of each kept --
|
|
11309
|
+
// the dot. The desktop has it only as a menu item, so it is drawn to
|
|
11310
|
+
// match the midpoint glyph's weight.
|
|
11311
|
+
function glyphRepresentatives() {
|
|
11312
|
+
return '<path d="M10,30 V70 M10,30 H38 M10,70 H38 M38,16 V44 M38,56 V84'
|
|
11313
|
+
+ ' M38,16 H66 M38,44 H66 M38,56 H66 M38,84 H66"/>'
|
|
11314
|
+
+ glyphDot(84, 16, 10)
|
|
11315
|
+
+ glyphDot(84, 84, 10);
|
|
11316
|
+
}
|
|
11317
|
+
|
|
10773
11318
|
// Build one glyph as an inline <svg>. Stroke weight, caps and joins follow
|
|
10774
11319
|
// the desktop class each glyph came from.
|
|
10775
11320
|
function makeGlyph(kind) {
|
|
@@ -10794,6 +11339,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
10794
11339
|
case 'ladderize_asc': sw = 8; cap = 'round'; body = glyphLadderize(true); break;
|
|
10795
11340
|
case 'ladderize_desc': sw = 8; cap = 'round'; body = glyphLadderize(false); break;
|
|
10796
11341
|
case 'midpoint': sw = 8; cap = 'round'; body = glyphMidpoint(); break;
|
|
11342
|
+
case 'representatives': sw = 8; cap = 'round'; body = glyphRepresentatives(); break;
|
|
10797
11343
|
case 'uncollapse_all': sw = 8.5; cap = 'round'; body = glyphUncollapseAll(); break;
|
|
10798
11344
|
case 'tree_prev': sw = 9; cap = 'round'; body = glyphChevron(false); break;
|
|
10799
11345
|
case 'tree_next': sw = 9; cap = 'round'; body = glyphChevron(true); break;
|
|
@@ -10985,6 +11531,29 @@ function (root, d3, forester, phyloXml) {
|
|
|
10985
11531
|
+ '.aptx-keys { display:inline-flex; gap:3px; flex-wrap:wrap; }'
|
|
10986
11532
|
+ '.aptx-kbd { font-family:inherit; font-size:10.5px; line-height:1; padding:3px 5px; min-width:18px; text-align:center; color:var(--p-ink); background:var(--p-surface2); border:1px solid var(--p-line-strong); border-bottom-width:2px; border-radius:4px; }'
|
|
10987
11533
|
+ '.aptx-shortcuts-note { margin:8px 0 0; font-size:10.5px; line-height:1.4; color:var(--p-muted); }'
|
|
11534
|
+
// representative tips: the input dialog and its result
|
|
11535
|
+
+ '.aptx-reps { line-height:1.35; }'
|
|
11536
|
+
+ '.aptx-reps-lead { margin:0 0 4px; }'
|
|
11537
|
+
+ '.aptx-reps-choice { display:flex; align-items:center; gap:6px; padding:2px 0; cursor:pointer; }'
|
|
11538
|
+
+ '.aptx-reps-choice input { accent-color:var(--p-accent); width:13px; height:13px; margin:0; flex:none; }'
|
|
11539
|
+
+ '.aptx-reps-disabled { color:var(--p-faint); cursor:default; }'
|
|
11540
|
+
+ '.aptx-reps-input { display:block; box-sizing:border-box; width:100%; font:inherit; color:var(--p-ink);'
|
|
11541
|
+
+ ' background:var(--p-surface2); border:1px solid var(--p-line-strong); border-radius:6px; padding:4px 7px; }'
|
|
11542
|
+
+ '.aptx-reps-input:focus { outline:none; border-color:var(--p-accent); box-shadow:0 0 0 3px var(--p-accent-weak); }'
|
|
11543
|
+
+ '.aptx-reps-note { margin-top:3px; font-size:10.5px; color:var(--p-muted); }'
|
|
11544
|
+
+ '.aptx-reps-protect { margin-top:10px; }'
|
|
11545
|
+
+ '.aptx-reps-error { margin-top:6px; font-size:10.5px; color:#e5484d; }'
|
|
11546
|
+
+ '.aptx-reps-error:empty { display:none; }'
|
|
11547
|
+
+ '.aptx-reps-line { padding:1px 0; }'
|
|
11548
|
+
+ '.aptx-reps-question { margin-top:9px; font-weight:600; }'
|
|
11549
|
+
+ '.aptx-reps-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:12px; }'
|
|
11550
|
+
+ '.aptx-reps-button { font:inherit; font-size:11px; height:26px; padding:0 12px; color:var(--p-ink);'
|
|
11551
|
+
+ ' background:var(--p-surface2); border:1px solid var(--p-line-strong); border-radius:6px; cursor:pointer;'
|
|
11552
|
+
+ ' transition:background .12s,border-color .12s,color .12s; }'
|
|
11553
|
+
+ '.aptx-reps-button:hover,.aptx-reps-primary { background:var(--p-accent-weak); border-color:var(--p-accent);'
|
|
11554
|
+
+ ' color:var(--p-accent-ink); }'
|
|
11555
|
+
+ '.aptx-reps-primary { font-weight:600; }'
|
|
11556
|
+
+ '.aptx-reps-button:focus-visible { outline:none; box-shadow:0 0 0 3px var(--p-accent-weak); }'
|
|
10988
11557
|
+ '.aptx-dialog-mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Courier New",monospace;'
|
|
10989
11558
|
+ ' font-size:11px; white-space:pre-wrap; overflow-wrap:anywhere; }'
|
|
10990
11559
|
// The hover tooltip, on the same palette.
|
|
@@ -11123,7 +11692,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
11123
11692
|
+ '.aptx-panel .aptx-slider-row input[type=range] { flex:1 1 auto; min-width:0; margin:0; }'
|
|
11124
11693
|
+ '.aptx-panel .aptx-linkbtn { background:none; border:0; padding:1px 0 2px; margin:0; font:inherit; font-size:10px; color:var(--p-accent); cursor:pointer; display:block; }'
|
|
11125
11694
|
+ '.aptx-panel .aptx-linkbtn:hover { text-decoration:underline; }'
|
|
11126
|
-
|
|
11695
|
+
// one row, its buttons sharing it evenly, its edges on the zoom rows'
|
|
11696
|
+
+ '.aptx-panel .aptx-toolrow { margin-top:5px; display:flex; gap:3px; }'
|
|
11697
|
+
+ '.aptx-panel .aptx-toolrow .aptx-gbtn { flex:1 1 0; min-width:0; padding:0; margin:2px 0; }'
|
|
11127
11698
|
+ '.aptx-panel .aptx-searchnav { align-items:center; gap:4px; margin:2px 0 4px; }'
|
|
11128
11699
|
+ '.aptx-panel .aptx-searchnav span { flex:1 1 auto; text-align:center; font-weight:600; font-size:11px; color:var(--p-ink); }'
|
|
11129
11700
|
+ '.aptx-panel .aptx-searchnav .aptx-gbtn:last-child { margin-right:0; }'
|
|
@@ -11167,7 +11738,28 @@ function (root, d3, forester, phyloXml) {
|
|
|
11167
11738
|
+ '.aptx-panel .aptx-hide-btn { flex:none; width:20px; height:20px; display:grid; place-items:center; padding:0; border:1px solid var(--p-line-strong); border-radius:6px; background:var(--p-surface2); color:var(--p-muted); cursor:pointer; font-size:15px; line-height:1; }'
|
|
11168
11739
|
+ '.aptx-panel .aptx-hide-btn:hover { background:var(--p-accent-weak); color:var(--p-accent-ink); border-color:var(--p-accent); }'
|
|
11169
11740
|
+ '.aptx-panel.aptx-hidden > .aptx-body { display:none; }'
|
|
11170
|
-
+ '.aptx-panel.aptx-hidden > .' + PROG_NAME + ' { border-bottom:0; }'
|
|
11741
|
+
+ '.aptx-panel.aptx-hidden > .' + PROG_NAME + ' { border-bottom:0; }'
|
|
11742
|
+
// --- compact density (the panelDensity config key) ---------------
|
|
11743
|
+
// Nothing is removed and nothing is hidden: the same controls,
|
|
11744
|
+
// tighter, for a host with little room to give. The width lives
|
|
11745
|
+
// here as well, and panelWidth() returns the same number so the
|
|
11746
|
+
// tree's left margin follows it (leftPanelClearance).
|
|
11747
|
+
+ '.aptx-panel.aptx-compact { width:' + PANEL_WIDTH_COMPACT + 'px; font-size:10.5px; }'
|
|
11748
|
+
+ '.aptx-panel.aptx-compact fieldset { padding:4px 9px; }'
|
|
11749
|
+
+ '.aptx-panel.aptx-compact legend { margin:0 0 3px; }'
|
|
11750
|
+
+ '.aptx-panel.aptx-compact .' + PROG_NAME + ' { padding:6px 9px; }'
|
|
11751
|
+
// the title is a fixed 12px, which wrapped onto a second line once
|
|
11752
|
+
// the panel narrowed: it comes down with everything else
|
|
11753
|
+
+ '.aptx-panel.aptx-compact .' + PROGNAMELINK + ' { font-size:10.5px; }'
|
|
11754
|
+
+ '.aptx-panel.aptx-compact .aptx-checkgrid { gap:2px 8px; }'
|
|
11755
|
+
+ '.aptx-panel.aptx-compact .aptx-field-label { margin:5px 0 2px; }'
|
|
11756
|
+
+ '.aptx-panel.aptx-compact .aptx-subhead { margin:4px 0 2px; }'
|
|
11757
|
+
+ '.aptx-panel.aptx-compact input[type=range] { margin:1px 0 6px; }'
|
|
11758
|
+
+ '.aptx-panel.aptx-compact .aptx-slider-row { margin:2px 0; }'
|
|
11759
|
+
+ '.aptx-panel.aptx-compact input[type=button], .aptx-panel.aptx-compact .aptx-gbtn { height:22px; margin:1px 2px 1px 0; }'
|
|
11760
|
+
+ '.aptx-panel.aptx-compact .aptx-search-row input[type=text], .aptx-panel.aptx-compact .aptx-search-row input[type=button] { height:24px; }'
|
|
11761
|
+
+ '.aptx-panel.aptx-compact .aptx-search-menus select { height:22px; }'
|
|
11762
|
+
+ '.aptx-panel.aptx-compact .aptx-toolrow { margin-top:3px; }';
|
|
11171
11763
|
let style = document.createElement('style');
|
|
11172
11764
|
style.id = PANEL_STYLE_ID;
|
|
11173
11765
|
style.textContent = css;
|
|
@@ -11256,6 +11848,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
11256
11848
|
// Collapsible sections: wrap each titled fieldset's content in a body
|
|
11257
11849
|
// element (so collapsing hides everything under the legend, including
|
|
11258
11850
|
// bare text labels like the slider captions) and toggle it via the legend.
|
|
11851
|
+
let initiallyOpen = [];
|
|
11259
11852
|
let fieldsets = body.querySelectorAll('fieldset');
|
|
11260
11853
|
for (let i = 0; i < fieldsets.length; ++i) {
|
|
11261
11854
|
let fieldset = fieldsets[i];
|
|
@@ -11273,10 +11866,34 @@ function (root, d3, forester, phyloXml) {
|
|
|
11273
11866
|
}
|
|
11274
11867
|
fieldset.appendChild(fsBody);
|
|
11275
11868
|
legend.classList.add('aptx-legend-toggle');
|
|
11869
|
+
let name = legend.textContent.trim();
|
|
11870
|
+
if (sectionFoldedInitially(name)) {
|
|
11871
|
+
fieldset.classList.add('aptx-collapsed');
|
|
11872
|
+
} else {
|
|
11873
|
+
initiallyOpen.push(name);
|
|
11874
|
+
}
|
|
11276
11875
|
legend.addEventListener('click', function () {
|
|
11277
|
-
fieldset.classList.toggle('aptx-collapsed');
|
|
11876
|
+
let opened = !fieldset.classList.toggle('aptx-collapsed');
|
|
11877
|
+
loadPanelSections()[name] = !opened;
|
|
11878
|
+
if (opened) {
|
|
11879
|
+
_panelOpenOrder.set(name, ++_panelOpenSeq);
|
|
11880
|
+
fitPanelSections(panel, name);
|
|
11881
|
+
}
|
|
11882
|
+
savePanelSections();
|
|
11278
11883
|
});
|
|
11279
11884
|
}
|
|
11885
|
+
// What arrived open counts as opened, oldest at the BOTTOM: a panel is
|
|
11886
|
+
// read top-down, so when a short window forces something to fold on
|
|
11887
|
+
// arrival it should be the last section rather than the one describing
|
|
11888
|
+
// the tree at the top. Anything the user opens later is newer than all
|
|
11889
|
+
// of these, and folds after them.
|
|
11890
|
+
for (let i = initiallyOpen.length - 1; i >= 0; --i) {
|
|
11891
|
+
_panelOpenOrder.set(initiallyOpen[i], ++_panelOpenSeq);
|
|
11892
|
+
}
|
|
11893
|
+
// A remembered set of open sections can be taller than this window,
|
|
11894
|
+
// so the fit rule runs once on arrival too -- with no section to
|
|
11895
|
+
// spare, since the user opened none of them just now.
|
|
11896
|
+
fitPanelSections(panel, null);
|
|
11280
11897
|
|
|
11281
11898
|
// Apply the current light/dark choice to this (and every) panel.
|
|
11282
11899
|
applyPanelTheme();
|
|
@@ -11943,6 +12560,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
11943
12560
|
|
|
11944
12561
|
if (c0) {
|
|
11945
12562
|
c0.classList.add('aptx-panel');
|
|
12563
|
+
if (_settings.panelDensity === 'compact') {
|
|
12564
|
+
c0.classList.add('aptx-compact');
|
|
12565
|
+
}
|
|
11946
12566
|
setStyles(c0, {
|
|
11947
12567
|
'position': 'absolute',
|
|
11948
12568
|
'left': CONTROLS_0_LEFT_DEFAULT,
|
|
@@ -12208,6 +12828,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
12208
12828
|
|
|
12209
12829
|
on(MIDPOINT_ROOT_BUTTON, 'click', midpointRootButtonPressed);
|
|
12210
12830
|
|
|
12831
|
+
on(REPRESENTATIVES_BUTTON, 'click', representativesButtonPressed);
|
|
12832
|
+
|
|
12211
12833
|
// Search Controls
|
|
12212
12834
|
// ---------------
|
|
12213
12835
|
|
|
@@ -12415,9 +13037,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
12415
13037
|
|
|
12416
13038
|
// A file with several trees: previous / picker / next in one row,
|
|
12417
13039
|
// the picker listing every tree by its name (or its number). Each
|
|
12418
|
-
// opens
|
|
12419
|
-
// disabled at the ends: the panel is rebuilt per tree,
|
|
12420
|
-
// state set here is always current.
|
|
13040
|
+
// opens in the same container as you left it (showTree). The end
|
|
13041
|
+
// buttons are disabled at the ends: the panel is rebuilt per tree,
|
|
13042
|
+
// so the state set here is always current.
|
|
12421
13043
|
function makeTreePicker() {
|
|
12422
13044
|
let n = _trees.length;
|
|
12423
13045
|
let row = document.createElement('div');
|
|
@@ -12656,6 +13278,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
12656
13278
|
h = h.concat(makeGlyphButton('up_one_level', RETURN_TO_SUPERTREE_BUTTON_BY_ONE, 'move up by one level towards the complete tree (if in a sub-tree)'));
|
|
12657
13279
|
h = h.concat(makeGlyphButton('uncollapse_all', UNCOLLAPSE_ALL_BUTTON, 'uncollapse all'));
|
|
12658
13280
|
h = h.concat(makeGlyphButton('midpoint', MIDPOINT_ROOT_BUTTON, 're-root the tree: MAD or midpoint'));
|
|
13281
|
+
h = h.concat(makeGlyphButton('representatives', REPRESENTATIVES_BUTTON, 'select representative tips: one per group of close tips'));
|
|
12659
13282
|
h = h.concat('</div>');
|
|
12660
13283
|
h = h.concat('</fieldset>');
|
|
12661
13284
|
return h;
|
|
@@ -13020,6 +13643,16 @@ function (root, d3, forester, phyloXml) {
|
|
|
13020
13643
|
rerootButton.title = blocked
|
|
13021
13644
|
|| (_in_subtree ? 'return to the whole tree to re-root it' : 're-root the tree: MAD or midpoint');
|
|
13022
13645
|
}
|
|
13646
|
+
let repsButton = byId(REPRESENTATIVES_BUTTON);
|
|
13647
|
+
if (repsButton) {
|
|
13648
|
+
let repsBlocked = representativesBlockedReason();
|
|
13649
|
+
if (repsBlocked) {
|
|
13650
|
+
disableButton(repsButton);
|
|
13651
|
+
} else {
|
|
13652
|
+
enableButton(repsButton);
|
|
13653
|
+
}
|
|
13654
|
+
repsButton.title = repsBlocked || 'select representative tips: one per group of close tips';
|
|
13655
|
+
}
|
|
13023
13656
|
let b;
|
|
13024
13657
|
if (_foundNodes0 && !_searchBox0Empty) {
|
|
13025
13658
|
b = byId(RESET_SEARCH_A_BTN);
|