react-msaview 6.4.2 → 7.0.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 +6 -5
- package/bundle/index.js +100 -101
- package/bundle/index.js.map +4 -4
- package/dist/components/Loading.js +2 -2
- package/dist/components/Loading.js.map +1 -1
- package/dist/components/MSAViewer.d.ts +10 -2
- package/dist/components/MSAViewer.js +90 -2
- package/dist/components/MSAViewer.js.map +1 -1
- package/dist/components/ResizeHandles.d.ts +3 -3
- package/dist/components/ResizeHandles.js +28 -21
- package/dist/components/ResizeHandles.js.map +1 -1
- package/dist/components/dialogs/{InterProScanDialog.d.ts → AnnotationFileDialog.d.ts} +2 -2
- package/dist/components/dialogs/AnnotationFileDialog.js +92 -0
- package/dist/components/dialogs/AnnotationFileDialog.js.map +1 -0
- package/dist/components/dialogs/FeatureDialog.js +13 -9
- package/dist/components/dialogs/FeatureDialog.js.map +1 -1
- package/dist/components/header/Header.js +4 -0
- package/dist/components/header/Header.js.map +1 -1
- package/dist/components/header/HeaderStatusArea.js +0 -2
- package/dist/components/header/HeaderStatusArea.js.map +1 -1
- package/dist/components/header/LoadWarnings.d.ts +6 -0
- package/dist/components/header/LoadWarnings.js +36 -0
- package/dist/components/header/LoadWarnings.js.map +1 -0
- package/dist/components/header/MultiAlignmentSelector.js +0 -0
- package/dist/components/header/MultiAlignmentSelector.js.map +1 -1
- package/dist/components/header/TreeSettingsMenu.js +9 -1
- package/dist/components/header/TreeSettingsMenu.js.map +1 -1
- package/dist/components/header/UnshareableDataWarning.d.ts +6 -0
- package/dist/components/header/UnshareableDataWarning.js +42 -0
- package/dist/components/header/UnshareableDataWarning.js.map +1 -0
- package/dist/components/header/getAnnotationsMenu.js +14 -14
- package/dist/components/header/getAnnotationsMenu.js.map +1 -1
- package/dist/components/import/ImportForm.js +1 -1
- package/dist/components/import/ImportForm.js.map +1 -1
- package/dist/components/import/ImportFormExamples.js +2 -6
- package/dist/components/import/ImportFormExamples.js.map +1 -1
- package/dist/components/import/util.js +3 -0
- package/dist/components/import/util.js.map +1 -1
- package/dist/components/msa/MSACanvasBlock.js +4 -5
- package/dist/components/msa/MSACanvasBlock.js.map +1 -1
- package/dist/components/msa/msaRaster.d.ts +13 -3
- package/dist/components/msa/msaRaster.js +173 -61
- package/dist/components/msa/msaRaster.js.map +1 -1
- package/dist/components/msa/renderHighlights.d.ts +39 -1
- package/dist/components/msa/renderHighlights.js +71 -6
- package/dist/components/msa/renderHighlights.js.map +1 -1
- package/dist/components/msa/renderMSABlock.d.ts +1 -2
- package/dist/components/msa/renderMSABlock.js +24 -28
- package/dist/components/msa/renderMSABlock.js.map +1 -1
- package/dist/components/msa/renderMSAMouseover.js +9 -30
- package/dist/components/msa/renderMSAMouseover.js.map +1 -1
- package/dist/components/renderCtx.d.ts +1 -1
- package/dist/components/tracks/TrackBlocks.js +10 -11
- package/dist/components/tracks/TrackBlocks.js.map +1 -1
- package/dist/components/tracks/drawTracks.d.ts +26 -8
- package/dist/components/tracks/drawTracks.js +123 -13
- package/dist/components/tracks/drawTracks.js.map +1 -1
- package/dist/components/tree/TreeCanvas.js +2 -3
- package/dist/components/tree/TreeCanvas.js.map +1 -1
- package/dist/components/tree/TreeCanvasBlock.js +4 -2
- package/dist/components/tree/TreeCanvasBlock.js.map +1 -1
- package/dist/components/tree/TreeRuler.js +13 -3
- package/dist/components/tree/TreeRuler.js.map +1 -1
- package/dist/components/tree/renderTreeCanvas.d.ts +0 -1
- package/dist/components/tree/renderTreeCanvas.js +70 -30
- package/dist/components/tree/renderTreeCanvas.js.map +1 -1
- package/dist/components/tree/scaleBar.d.ts +10 -0
- package/dist/components/tree/scaleBar.js +24 -0
- package/dist/components/tree/scaleBar.js.map +1 -0
- package/dist/constants.d.ts +3 -1
- package/dist/constants.js +18 -1
- package/dist/constants.js.map +1 -1
- package/dist/fetchUtils.d.ts +7 -7
- package/dist/fetchUtils.js +4 -32
- package/dist/fetchUtils.js.map +1 -1
- package/dist/hierarchy.d.ts +2 -1
- package/dist/hierarchy.js +16 -10
- package/dist/hierarchy.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/model/DataModel.js +5 -2
- package/dist/model/DataModel.js.map +1 -1
- package/dist/model.d.ts +454 -177
- package/dist/model.js +1180 -511
- package/dist/model.js.map +1 -1
- package/dist/renderToStaticMarkup.d.ts +11 -0
- package/dist/renderToStaticMarkup.js +30 -7
- package/dist/renderToStaticMarkup.js.map +1 -1
- package/dist/renderToSvg.js +25 -19
- package/dist/renderToSvg.js.map +1 -1
- package/dist/rowCoordinateCalculations.d.ts +25 -25
- package/dist/rowCoordinateCalculations.js +12 -36
- package/dist/rowCoordinateCalculations.js.map +1 -1
- package/dist/seqPosToGlobalCol.js +3 -1
- package/dist/seqPosToGlobalCol.js.map +1 -1
- package/dist/svgTestUtil.d.ts +144 -82
- package/dist/types.d.ts +97 -3
- package/dist/util.d.ts +11 -4
- package/dist/util.js +30 -10
- package/dist/util.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/wuss.d.ts +14 -0
- package/dist/wuss.js +55 -0
- package/dist/wuss.js.map +1 -0
- package/package.json +10 -8
- package/src/components/Loading.tsx +12 -2
- package/src/components/MSAViewer.tsx +106 -2
- package/src/components/ResizeHandles.tsx +31 -31
- package/src/components/dialogs/AnnotationFileDialog.tsx +162 -0
- package/src/components/dialogs/FeatureDialog.tsx +13 -9
- package/src/components/header/Header.tsx +4 -0
- package/src/components/header/HeaderStatusArea.tsx +0 -5
- package/src/components/header/LoadWarnings.tsx +49 -0
- package/src/components/header/MultiAlignmentSelector.tsx +0 -0
- package/src/components/header/TreeSettingsMenu.tsx +9 -1
- package/src/components/header/UnshareableDataWarning.tsx +58 -0
- package/src/components/header/getAnnotationsMenu.ts +16 -17
- package/src/components/import/ImportForm.tsx +2 -2
- package/src/components/import/ImportFormExamples.tsx +2 -6
- package/src/components/import/util.ts +3 -0
- package/src/components/msa/MSACanvasBlock.tsx +4 -5
- package/src/components/msa/msaRaster.ts +216 -59
- package/src/components/msa/renderHighlights.ts +103 -5
- package/src/components/msa/renderMSABlock.ts +25 -36
- package/src/components/msa/renderMSAMouseover.ts +9 -39
- package/src/components/renderCtx.ts +1 -0
- package/src/components/tracks/TrackBlocks.tsx +10 -13
- package/src/components/tracks/drawTracks.ts +165 -17
- package/src/components/tree/TreeCanvas.tsx +2 -3
- package/src/components/tree/TreeCanvasBlock.tsx +4 -2
- package/src/components/tree/TreeRuler.tsx +38 -3
- package/src/components/tree/renderTreeCanvas.ts +79 -35
- package/src/components/tree/scaleBar.ts +23 -0
- package/src/constants.ts +20 -1
- package/src/fetchUtils.ts +7 -40
- package/src/hierarchy.ts +20 -11
- package/src/index.ts +18 -2
- package/src/model/DataModel.ts +6 -2
- package/src/model.ts +1352 -616
- package/src/rawImports.d.ts +9 -0
- package/src/renderToStaticMarkup.ts +34 -7
- package/src/renderToSvg.tsx +33 -27
- package/src/rowCoordinateCalculations.ts +15 -59
- package/src/seqPosToGlobalCol.ts +3 -1
- package/src/types.ts +131 -4
- package/src/util.ts +31 -14
- package/src/version.ts +1 -1
- package/src/wuss.ts +62 -0
- package/dist/components/dialogs/InterProScanDialog.js +0 -137
- package/dist/components/dialogs/InterProScanDialog.js.map +0 -1
- package/dist/components/dialogs/InterProScanFileDialog.d.ts +0 -7
- package/dist/components/dialogs/InterProScanFileDialog.js +0 -64
- package/dist/components/dialogs/InterProScanFileDialog.js.map +0 -1
- package/dist/launchInterProScan.d.ts +0 -13
- package/dist/launchInterProScan.js +0 -110
- package/dist/launchInterProScan.js.map +0 -1
- package/dist/useColorContrast.d.ts +0 -10
- package/dist/useColorContrast.js +0 -13
- package/dist/useColorContrast.js.map +0 -1
- package/src/clustalX.test.ts +0 -74
- package/src/colSpace.test.ts +0 -67
- package/src/collapseLogic.test.ts +0 -115
- package/src/colorSchemes.docs.test.ts +0 -57
- package/src/columnCounts.test.ts +0 -73
- package/src/columnStats.test.ts +0 -94
- package/src/columnTracks.test.ts +0 -97
- package/src/components/dialogs/InterProScanDialog.tsx +0 -214
- package/src/components/dialogs/InterProScanFileDialog.tsx +0 -138
- package/src/components/minimap/minimapLayout.test.ts +0 -71
- package/src/components/msa/msaRaster.test.ts +0 -145
- package/src/components/msa/renderBoxFeatureCanvasBlock.test.ts +0 -51
- package/src/components/tree/renderTreeCanvas.test.ts +0 -296
- package/src/dataInitialized.test.ts +0 -42
- package/src/domainBands.test.ts +0 -122
- package/src/domainLetterColor.test.ts +0 -80
- package/src/exportFileName.test.ts +0 -40
- package/src/fetchUtils.test.ts +0 -83
- package/src/flatToTree.test.ts +0 -51
- package/src/hierarchy.test.ts +0 -228
- package/src/highlights.test.ts +0 -70
- package/src/impgRender.test.tsx +0 -68
- package/src/largeTree.test.ts +0 -67
- package/src/launchInterProScan.ts +0 -167
- package/src/loadErrorDisplay.test.tsx +0 -102
- package/src/modelFilehandleLoaders.test.ts +0 -137
- package/src/modelReset.test.ts +0 -120
- package/src/neighborJoining.test.ts +0 -164
- package/src/parseAsn1.test.ts +0 -36
- package/src/propertyConservation.test.ts +0 -36
- package/src/renderColumnTracksSvg.test.tsx +0 -108
- package/src/renderDomainsSvg.test.tsx +0 -118
- package/src/renderHighlightsSvg.test.tsx +0 -85
- package/src/renderMinimapSvg.test.tsx +0 -62
- package/src/renderRasterSvg.test.tsx +0 -144
- package/src/renderSequenceLogoSvg.test.tsx +0 -165
- package/src/renderToSvg.test.tsx +0 -65
- package/src/reset.test.ts +0 -61
- package/src/rowCoordinateCalculations.test.ts +0 -211
- package/src/rowSpace.test.ts +0 -46
- package/src/segmentOverlay.test.ts +0 -70
- package/src/seqPosToGlobalCol.test.ts +0 -59
- package/src/sequenceLogo.test.ts +0 -88
- package/src/setFontSize.test.ts +0 -33
- package/src/stripDefault.test.ts +0 -48
- package/src/stripDefaultSnapshot.test.ts +0 -87
- package/src/treeMetadata.test.ts +0 -39
- package/src/useCanvasAutorun.test.tsx +0 -98
- package/src/useColorContrast.ts +0 -18
- package/src/viewChrome.test.tsx +0 -134
- package/src/wheelZoom.test.ts +0 -46
package/dist/model.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { clamp, groupBy, notEmpty, sum } from '@jbrowse/core/util';
|
|
2
2
|
import { openLocation } from '@jbrowse/core/util/io';
|
|
3
3
|
import { ElementId, FileLocation } from '@jbrowse/core/util/types/mst';
|
|
4
|
-
import { addDisposer, applySnapshot, cast, getSnapshot, types, } from '@jbrowse/mobx-state-tree';
|
|
4
|
+
import { addDisposer, applySnapshot, cast, getSnapshot, isAlive, types, } from '@jbrowse/mobx-state-tree';
|
|
5
5
|
import { colord } from 'colord';
|
|
6
6
|
import { autorun, transaction } from 'mobx';
|
|
7
7
|
import { generateNodeIds, gffToAnnotations, interProScanToAnnotations, parseEmfTree, parseGFF, parseMSA, parseNewick, } from 'msa-parsers';
|
|
@@ -10,12 +10,12 @@ import { clustalXColumnColors } from './clustalX.js';
|
|
|
10
10
|
import colorSchemes from './colorSchemes.js';
|
|
11
11
|
import { columnCountsFromRows, letterOfResidueSlot } from './columnCounts.js';
|
|
12
12
|
import TrackBlocks from './components/tracks/TrackBlocks.js';
|
|
13
|
-
import { defaultAllowedGappyness, defaultColWidth, defaultColorSchemeName, defaultCurrentAlignment, defaultDrawMsaLetters, defaultHeight, defaultHideGaps, defaultRowHeight, defaultScrollX, defaultScrollY, defaultScrollZoom, defaultShowDomainLegend, defaultShowDomains, defaultSubFeatureRows, labelReferenceFontSize, maxCellSize, minColWidth, minLetterColWidth, minLetterRowHeight, minRowHeight, segmentFeatureTypes, segmentShades, } from './constants.js';
|
|
13
|
+
import { defaultAllowedGappyness, defaultColWidth, defaultColorSchemeName, defaultCurrentAlignment, defaultDrawMsaLetters, defaultHeight, defaultHideGaps, defaultRowHeight, defaultScrollX, defaultScrollY, defaultScrollZoom, defaultShowDomainLegend, defaultShowDomains, defaultSubFeatureRows, defaultTreeWidth, labelReferenceFontSize, maxCellSize, maxInlineSnapshotBytes, maxNeighborJoiningRows, minColWidth, minLetterColWidth, minLetterRowHeight, minRowHeight, segmentFeatureTypes, segmentShades, } from './constants.js';
|
|
14
14
|
import { createPaletteMap } from './createPaletteMap.js';
|
|
15
15
|
import { exportFileName } from './exportFileName.js';
|
|
16
16
|
import { fetchTextWithProgress, isAbortError } from './fetchUtils.js';
|
|
17
17
|
import { flatToTree } from './flatToTree.js';
|
|
18
|
-
import { calcDepthToLeaf, clusterLayout, collapse, collapsedSubtreeMaxLength, find,
|
|
18
|
+
import { calcDepthToLeaf, clusterLayout, collapse, collapsedSubtreeMaxLength, find, forEachDescendant, hierarchy, leaves, maxLength, setBrLength, sort, sum as hierarchySum, } from './hierarchy.js';
|
|
19
19
|
import { measureTextCanvas } from './measureTextCanvas.js';
|
|
20
20
|
import { DataModelF } from './model/DataModel.js';
|
|
21
21
|
import { DialogQueueSessionMixin } from './model/DialogQueue.js';
|
|
@@ -24,12 +24,13 @@ import { TreeModelF } from './model/treeModel.js';
|
|
|
24
24
|
import { calculateNeighborJoiningTree } from './neighborJoining.js';
|
|
25
25
|
import { parseAsn1 } from './parseAsn1.js';
|
|
26
26
|
import { calculatePropertyConservation } from './propertyConservation.js';
|
|
27
|
-
import { globalColToVisibleCol,
|
|
27
|
+
import { globalColToVisibleCol, seqPosOfGlobalCol, visibleColToGlobalCol, visibleColsBefore, } from './rowCoordinateCalculations.js';
|
|
28
28
|
import { buildSeqPosIndex } from './seqPosToGlobalCol.js';
|
|
29
29
|
import { maxBitsFor } from './sequenceLogo.js';
|
|
30
30
|
import { stripDefault } from './stripDefault.js';
|
|
31
31
|
import { computeRowInsertions, dropBlanks, len, skipBlanks, transform, } from './util.js';
|
|
32
32
|
import { saveAs } from './vendor/fileSaver.js';
|
|
33
|
+
import { parseWuss } from './wuss.js';
|
|
33
34
|
function parseTreeText(text) {
|
|
34
35
|
if (text.startsWith('BioTreeContainer')) {
|
|
35
36
|
return flatToTree(parseAsn1(text));
|
|
@@ -39,12 +40,19 @@ function parseTreeText(text) {
|
|
|
39
40
|
// Tracks that start hidden. The sequence logo answers a narrower question than
|
|
40
41
|
// conservation does and costs three times the vertical space, so it waits to be
|
|
41
42
|
// asked for.
|
|
42
|
-
const defaultOffTracks = new Set(['sequence-logo']);
|
|
43
|
+
const defaultOffTracks = new Set(['sequence-logo', 'position-ruler']);
|
|
44
|
+
// base-pair arcs: one color for the nested helices, one for a pseudoknot, whose
|
|
45
|
+
// whole point is that it crosses them
|
|
46
|
+
const HELIX_ARC = '#4e79a7';
|
|
47
|
+
const PSEUDOKNOT_ARC = '#e15759';
|
|
43
48
|
// a data track over this size stays in the live model but leaves the snapshot,
|
|
44
49
|
// the same rule DataModel applies to an inline document
|
|
45
|
-
|
|
50
|
+
function columnTrackSizes(tracks) {
|
|
51
|
+
return (tracks ?? []).map(t => JSON.stringify(t).length);
|
|
52
|
+
}
|
|
46
53
|
function smallColumnTracks(tracks) {
|
|
47
|
-
const
|
|
54
|
+
const sizes = columnTrackSizes(tracks);
|
|
55
|
+
const kept = tracks?.filter((_t, i) => sizes[i] <= maxInlineSnapshotBytes);
|
|
48
56
|
return kept?.length ? { columnTracks: kept } : {};
|
|
49
57
|
}
|
|
50
58
|
/**
|
|
@@ -90,8 +98,38 @@ export const preservedOnReset = new Set([
|
|
|
90
98
|
// until they touch that track, and then its value is whether the track is OFF.
|
|
91
99
|
// Reading the default through this is what lets a track ship hidden without
|
|
92
100
|
// writing an entry into every snapshot and shared URL.
|
|
93
|
-
|
|
94
|
-
|
|
101
|
+
// A track the file itself supplies says whether it starts hidden, since only
|
|
102
|
+
// the file knows how many of them there are: a Pfam seed carries a couple of
|
|
103
|
+
// #=GR lines, an Rfam family one per row.
|
|
104
|
+
function trackIsOff(turnedOffTracks, id, defaultOff) {
|
|
105
|
+
return turnedOffTracks.get(id) ?? (defaultOff || defaultOffTracks.has(id));
|
|
106
|
+
}
|
|
107
|
+
// one array for every "nothing under the pointer", since a fresh [] is a fresh
|
|
108
|
+
// value to every observer of it
|
|
109
|
+
const noDomains = [];
|
|
110
|
+
// seqPos -> column indexes, per row, hung off the parse the rows came from so
|
|
111
|
+
// they are collected with it. A computed would rebuild every row's index on
|
|
112
|
+
// each miss, and rebuild all of them again whenever it was read outside a
|
|
113
|
+
// reactive context.
|
|
114
|
+
const seqPosIndexCache = new WeakMap();
|
|
115
|
+
// A segment asserts a 1:1 run, so its two sides have to be the same length.
|
|
116
|
+
// One that is not is malformed data, and the arithmetic below would answer
|
|
117
|
+
// anyway -- with a residue that is off by however much the sides disagree. Skip
|
|
118
|
+
// it, the same refusal an uncovered position gets.
|
|
119
|
+
function sameLength(segment) {
|
|
120
|
+
return (segment.rowEnd - segment.rowStart ===
|
|
121
|
+
segment.structEnd - segment.structStart);
|
|
122
|
+
}
|
|
123
|
+
// Does the content need a scrollbar? fit() divides the viewport by the row or
|
|
124
|
+
// column count and multiplies it back, so an exact fit lands a fraction of a
|
|
125
|
+
// pixel over -- enough for a `>` to answer yes and hand the reader a minimap or
|
|
126
|
+
// a scrollbar for half a pixel of nothing, which then shrinks the viewport and
|
|
127
|
+
// leaves a gap.
|
|
128
|
+
function overflows(content, viewport) {
|
|
129
|
+
return content - viewport > 0.5;
|
|
130
|
+
}
|
|
131
|
+
function inRanges(ranges, position) {
|
|
132
|
+
return !!ranges?.some(([start, end]) => position >= start && position <= end);
|
|
95
133
|
}
|
|
96
134
|
/**
|
|
97
135
|
* #stateModel MsaView
|
|
@@ -236,6 +274,15 @@ function stateModelFactory() {
|
|
|
236
274
|
* text track. See docs/layers.md
|
|
237
275
|
*/
|
|
238
276
|
columnTracks: stripDefault(types.array(types.frozen()), []),
|
|
277
|
+
/**
|
|
278
|
+
* #property
|
|
279
|
+
* which residue of which structure each row's residues are, as data.
|
|
280
|
+
* The viewer cannot infer this -- matching a row to a structure by
|
|
281
|
+
* sequence equality fails for a tagged construct, a truncation or a
|
|
282
|
+
* subsequence row, and fails in the direction that looks like it
|
|
283
|
+
* worked -- so it arrives computed. See docs/layers.md
|
|
284
|
+
*/
|
|
285
|
+
residueMappings: stripDefault(types.array(types.frozen()), []),
|
|
239
286
|
/**
|
|
240
287
|
* #property
|
|
241
288
|
* data from the loaded tree/msa/treeMetadata, generally loaded by
|
|
@@ -248,8 +295,13 @@ function stateModelFactory() {
|
|
|
248
295
|
}),
|
|
249
296
|
/**
|
|
250
297
|
* #property
|
|
298
|
+
* the user's explicit hide choices per annotation accession, keyed by
|
|
299
|
+
* accession with the value meaning "off", the same shape as
|
|
300
|
+
* `turnedOffTracks`. An accession the user has never touched is absent
|
|
301
|
+
* and drawn, so a file of two hundred domain types adds nothing to the
|
|
302
|
+
* shared URL until someone filters one out
|
|
251
303
|
*/
|
|
252
|
-
|
|
304
|
+
turnedOffFeatures: stripDefault(types.map(types.boolean), {}),
|
|
253
305
|
/**
|
|
254
306
|
* #property
|
|
255
307
|
*/
|
|
@@ -340,6 +392,15 @@ function stateModelFactory() {
|
|
|
340
392
|
* array of column indices to highlight
|
|
341
393
|
*/
|
|
342
394
|
highlightedColumns: undefined,
|
|
395
|
+
/**
|
|
396
|
+
* #volatile
|
|
397
|
+
* transient highlights keyed by who asked for them. One slot cannot hold
|
|
398
|
+
* two sources -- a structure viewer's hover and a genome view's hover
|
|
399
|
+
* both want to point at a column, and with one slot whoever clears last
|
|
400
|
+
* erases the other's. Keyed by owner, each source adds and removes only
|
|
401
|
+
* its own. Not persisted: a hover is not part of the document.
|
|
402
|
+
*/
|
|
403
|
+
transientHighlights: {},
|
|
343
404
|
/**
|
|
344
405
|
* #volatile
|
|
345
406
|
*/
|
|
@@ -348,6 +409,14 @@ function stateModelFactory() {
|
|
|
348
409
|
* #volatile
|
|
349
410
|
*/
|
|
350
411
|
conservationTrackHeight: 40,
|
|
412
|
+
/**
|
|
413
|
+
* #volatile
|
|
414
|
+
* heights of individual `columnTracks`, by track id. A data track is
|
|
415
|
+
* resized on its own: the shared per-kind heights below belong to the
|
|
416
|
+
* tracks the viewer computes, and dragging a data track's handle used to
|
|
417
|
+
* resize those instead.
|
|
418
|
+
*/
|
|
419
|
+
columnTrackHeights: {},
|
|
351
420
|
/**
|
|
352
421
|
* #volatile
|
|
353
422
|
* taller than the conservation track by default: the logo spends its
|
|
@@ -355,6 +424,10 @@ function stateModelFactory() {
|
|
|
355
424
|
* one too short to identify
|
|
356
425
|
*/
|
|
357
426
|
sequenceLogoTrackHeight: 80,
|
|
427
|
+
/**
|
|
428
|
+
* #volatile
|
|
429
|
+
*/
|
|
430
|
+
arcTrackHeight: 50,
|
|
358
431
|
/**
|
|
359
432
|
* #volatile
|
|
360
433
|
*/
|
|
@@ -364,6 +437,29 @@ function stateModelFactory() {
|
|
|
364
437
|
*/
|
|
365
438
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
366
439
|
error: undefined,
|
|
440
|
+
/**
|
|
441
|
+
* #volatile
|
|
442
|
+
* load problems the view carried on through: an optional layer that did
|
|
443
|
+
* not arrive, an overlay that did not parse. `error` is the other kind --
|
|
444
|
+
* it replaces the view, which is right for the alignment and wrong for a
|
|
445
|
+
* decorative file
|
|
446
|
+
*/
|
|
447
|
+
warnings: [],
|
|
448
|
+
/**
|
|
449
|
+
* #volatile
|
|
450
|
+
* bumped by reset(). The React error boundary above the view keeps its
|
|
451
|
+
* caught error until it is remounted, so "Return to import form" did
|
|
452
|
+
* nothing after a render error until this became its key
|
|
453
|
+
*/
|
|
454
|
+
resetCount: 0,
|
|
455
|
+
/**
|
|
456
|
+
* #volatile
|
|
457
|
+
* set by a host that restores the loaded documents by its own means --
|
|
458
|
+
* a jbrowse session that holds them, a page that refetches them on load.
|
|
459
|
+
* `unshareableData` then reports nothing, since what it warns about is a
|
|
460
|
+
* link that opens empty, and under such a host the link does not
|
|
461
|
+
*/
|
|
462
|
+
hostCarriesData: false,
|
|
367
463
|
/**
|
|
368
464
|
* #volatile
|
|
369
465
|
* overlay annotations drawn on the alignment, whatever their source.
|
|
@@ -435,13 +531,35 @@ function stateModelFactory() {
|
|
|
435
531
|
setError(error) {
|
|
436
532
|
self.error = error;
|
|
437
533
|
},
|
|
534
|
+
/**
|
|
535
|
+
* #action
|
|
536
|
+
* report something the view survived: a layer that failed to load, a
|
|
537
|
+
* file that failed to parse
|
|
538
|
+
*/
|
|
539
|
+
addWarning(warning) {
|
|
540
|
+
self.warnings = [...self.warnings, warning];
|
|
541
|
+
},
|
|
542
|
+
/**
|
|
543
|
+
* #action
|
|
544
|
+
*/
|
|
545
|
+
clearWarnings() {
|
|
546
|
+
self.warnings = [];
|
|
547
|
+
},
|
|
548
|
+
/**
|
|
549
|
+
* #action
|
|
550
|
+
* declare that this host restores the loaded documents itself, which
|
|
551
|
+
* takes down the "Not in the link" warning. See `hostCarriesData`
|
|
552
|
+
*/
|
|
553
|
+
setHostCarriesData(arg) {
|
|
554
|
+
self.hostCarriesData = arg;
|
|
555
|
+
},
|
|
438
556
|
/**
|
|
439
557
|
* #action
|
|
440
558
|
* set mouse position (row, column) in the MSA
|
|
441
559
|
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
560
|
+
* PUBLIC API: a host drives this (and reads the `mouseCol` volatile) to
|
|
561
|
+
* sync the alignment's hover with a view of its own -- a genome view, a
|
|
562
|
+
* 3D structure. Keep the name and signature stable.
|
|
445
563
|
*/
|
|
446
564
|
setMousePos(col, row) {
|
|
447
565
|
self.mouseCol = col;
|
|
@@ -451,9 +569,9 @@ function stateModelFactory() {
|
|
|
451
569
|
* #action
|
|
452
570
|
* set highlighted columns
|
|
453
571
|
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
572
|
+
* PUBLIC API: jbrowse-plugin-msaview calls this from its
|
|
573
|
+
* afterCreateAutoruns to highlight alignment columns, and MSAViewer
|
|
574
|
+
* passes its `highlightColumns` prop through it. Not dead code.
|
|
457
575
|
*/
|
|
458
576
|
setHighlightedColumns(columns) {
|
|
459
577
|
self.highlightedColumns = columns;
|
|
@@ -464,6 +582,29 @@ function stateModelFactory() {
|
|
|
464
582
|
setHighlights(highlights) {
|
|
465
583
|
self.highlights.replace(highlights);
|
|
466
584
|
},
|
|
585
|
+
/**
|
|
586
|
+
* #action
|
|
587
|
+
* show `highlights` on behalf of `owner`, replacing whatever that owner
|
|
588
|
+
* showed before and leaving every other owner's alone. The object is
|
|
589
|
+
* replaced rather than mutated so one assignment is the observable
|
|
590
|
+
* change.
|
|
591
|
+
*/
|
|
592
|
+
applyHighlight(owner, highlights) {
|
|
593
|
+
self.transientHighlights = {
|
|
594
|
+
...self.transientHighlights,
|
|
595
|
+
[owner]: highlights,
|
|
596
|
+
};
|
|
597
|
+
},
|
|
598
|
+
/**
|
|
599
|
+
* #action
|
|
600
|
+
* drop what `owner` was showing, leaving every other owner's in place
|
|
601
|
+
*/
|
|
602
|
+
clearHighlight(owner) {
|
|
603
|
+
if (owner in self.transientHighlights) {
|
|
604
|
+
const { [owner]: _dropped, ...rest } = self.transientHighlights;
|
|
605
|
+
self.transientHighlights = rest;
|
|
606
|
+
}
|
|
607
|
+
},
|
|
467
608
|
/**
|
|
468
609
|
* #action
|
|
469
610
|
* toggle the annotation overlay on the alignment
|
|
@@ -508,10 +649,23 @@ function stateModelFactory() {
|
|
|
508
649
|
},
|
|
509
650
|
/**
|
|
510
651
|
* #action
|
|
511
|
-
*
|
|
652
|
+
* switch to another alignment of a multi-alignment file (Stockholm). The
|
|
653
|
+
* new alignment has its own rows and its own tree, so everything naming
|
|
654
|
+
* the old one's -- the collapsed node ids, the subtree in focus, the
|
|
655
|
+
* reference row, the scroll position -- goes with it
|
|
512
656
|
*/
|
|
513
657
|
setCurrentAlignment(n) {
|
|
514
|
-
self.currentAlignment
|
|
658
|
+
if (n === self.currentAlignment) {
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
transaction(() => {
|
|
662
|
+
self.currentAlignment = n;
|
|
663
|
+
self.collapsed.clear();
|
|
664
|
+
this.setShowOnly(undefined);
|
|
665
|
+
this.drawRelativeTo(undefined);
|
|
666
|
+
self.scrollX = 0;
|
|
667
|
+
self.scrollY = 0;
|
|
668
|
+
});
|
|
515
669
|
},
|
|
516
670
|
/**
|
|
517
671
|
* #action
|
|
@@ -583,6 +737,17 @@ function stateModelFactory() {
|
|
|
583
737
|
setTreeMetadata(result) {
|
|
584
738
|
self.data.setTreeMetadata(result);
|
|
585
739
|
},
|
|
740
|
+
/**
|
|
741
|
+
* #action
|
|
742
|
+
* keep the GFF text the way the alignment and the tree are kept, rather
|
|
743
|
+
* than only its parsed annotations. The annotations are volatile, so a
|
|
744
|
+
* file opened from disk used to leave no trace in the snapshot at all --
|
|
745
|
+
* not the text, and not the filehandle, which is cleared once a blob is
|
|
746
|
+
* read. An autorun parses this back into annotations.
|
|
747
|
+
*/
|
|
748
|
+
setGFF(result) {
|
|
749
|
+
self.data.setGFF(result);
|
|
750
|
+
},
|
|
586
751
|
}))
|
|
587
752
|
.views(self => ({
|
|
588
753
|
/**
|
|
@@ -605,6 +770,22 @@ function stateModelFactory() {
|
|
|
605
770
|
get actuallyShowDomains() {
|
|
606
771
|
return self.showDomains && self.annotations.length > 0;
|
|
607
772
|
},
|
|
773
|
+
/**
|
|
774
|
+
* #getter
|
|
775
|
+
* whether this host brings the loaded documents back by means the
|
|
776
|
+
* snapshot cannot see, which is what decides whether `unshareableData`
|
|
777
|
+
* has anything to warn about.
|
|
778
|
+
*
|
|
779
|
+
* A simple host flips the `hostCarriesData` volatile. A host whose
|
|
780
|
+
* answer depends on how the view was opened overrides this getter in a
|
|
781
|
+
* `.views` block of its own composed model -- jbrowse-plugin-msaview's
|
|
782
|
+
* indexed-location views refetch from a URL the session holds, while its
|
|
783
|
+
* data-store views really are absent from a link someone pastes
|
|
784
|
+
* elsewhere. `unshareableData` reads it off `self`, so an override wins.
|
|
785
|
+
*/
|
|
786
|
+
get hostRestoresData() {
|
|
787
|
+
return self.hostCarriesData;
|
|
788
|
+
},
|
|
608
789
|
get viewInitialized() {
|
|
609
790
|
return self.volatileWidth !== undefined;
|
|
610
791
|
},
|
|
@@ -619,13 +800,6 @@ function stateModelFactory() {
|
|
|
619
800
|
},
|
|
620
801
|
}))
|
|
621
802
|
.views(self => ({
|
|
622
|
-
/**
|
|
623
|
-
* #method
|
|
624
|
-
* unused here, but can be used by derived classes to add extra items
|
|
625
|
-
*/
|
|
626
|
-
extraViewMenuItems() {
|
|
627
|
-
return [];
|
|
628
|
-
},
|
|
629
803
|
/**
|
|
630
804
|
* #getter
|
|
631
805
|
*/
|
|
@@ -656,6 +830,46 @@ function stateModelFactory() {
|
|
|
656
830
|
get noDomains() {
|
|
657
831
|
return self.annotations.length === 0;
|
|
658
832
|
},
|
|
833
|
+
/**
|
|
834
|
+
* #getter
|
|
835
|
+
* the loaded documents this view's own snapshot cannot carry, largest
|
|
836
|
+
* first. A file opened from disk or pasted in becomes inline text, and
|
|
837
|
+
* DataModel drops an inline document past `maxInlineSnapshotBytes`
|
|
838
|
+
* rather than put megabytes of sequence into a session or a URL.
|
|
839
|
+
*
|
|
840
|
+
* Dropping it is right. Dropping it silently is what makes a copied link
|
|
841
|
+
* open an empty viewer, so the header says so and the standalone app
|
|
842
|
+
* stops rewriting the address bar while this is non-empty. A document
|
|
843
|
+
* fetched from a URL never appears here whatever its size: the snapshot
|
|
844
|
+
* keeps the filehandle and refetches through it.
|
|
845
|
+
*
|
|
846
|
+
* Nothing is unshareable when the host restores the data by its own
|
|
847
|
+
* means (see `hostRestoresData`) -- inside a session that reloads these
|
|
848
|
+
* documents from somewhere the snapshot does not show, the warning is
|
|
849
|
+
* simply wrong.
|
|
850
|
+
*/
|
|
851
|
+
get unshareableData() {
|
|
852
|
+
if (self.hostRestoresData) {
|
|
853
|
+
return [];
|
|
854
|
+
}
|
|
855
|
+
const { data } = self;
|
|
856
|
+
// a data track past the limit leaves the snapshot the same way an
|
|
857
|
+
// inline document does, and left unreported the same way too
|
|
858
|
+
const trackBytes = columnTrackSizes(self.columnTracks)
|
|
859
|
+
.filter(bytes => bytes > maxInlineSnapshotBytes)
|
|
860
|
+
.reduce((a, b) => a + b, 0);
|
|
861
|
+
return [
|
|
862
|
+
['alignment', data.msa, self.msaFilehandle],
|
|
863
|
+
['tree', data.tree, self.treeFilehandle],
|
|
864
|
+
['annotations', data.gff, self.gffFilehandle],
|
|
865
|
+
['row metadata', data.treeMetadata, self.treeMetadataFilehandle],
|
|
866
|
+
]
|
|
867
|
+
.flatMap(([what, text, filehandle]) => !filehandle && text && text.length > maxInlineSnapshotBytes
|
|
868
|
+
? [{ what, bytes: text.length }]
|
|
869
|
+
: [])
|
|
870
|
+
.concat(trackBytes ? [{ what: 'data tracks', bytes: trackBytes }] : [])
|
|
871
|
+
.sort((a, b) => b.bytes - a.bytes);
|
|
872
|
+
},
|
|
659
873
|
menuItems() {
|
|
660
874
|
return [];
|
|
661
875
|
},
|
|
@@ -872,19 +1086,48 @@ function stateModelFactory() {
|
|
|
872
1086
|
return this.leaves.length;
|
|
873
1087
|
},
|
|
874
1088
|
/**
|
|
875
|
-
* #
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
*
|
|
1089
|
+
* #method
|
|
1090
|
+
* index of the global column holding each ungapped sequence position of a
|
|
1091
|
+
* row, so seqPos -> column is a lookup rather than a scan. The domain
|
|
1092
|
+
* overlay resolves thousands of these per redraw.
|
|
1093
|
+
*
|
|
1094
|
+
* Built per row, on the row asked for: the first lookup used to index
|
|
1095
|
+
* every row in the alignment. The cache is keyed on the parse the rows
|
|
1096
|
+
* came from, so a new alignment brings a new one.
|
|
879
1097
|
*/
|
|
880
|
-
|
|
881
|
-
|
|
1098
|
+
seqPosIndex(rowName) {
|
|
1099
|
+
const MSA = this.MSA;
|
|
1100
|
+
if (!MSA) {
|
|
1101
|
+
return undefined;
|
|
1102
|
+
}
|
|
1103
|
+
let cache = seqPosIndexCache.get(MSA);
|
|
1104
|
+
if (!cache) {
|
|
1105
|
+
cache = new Map();
|
|
1106
|
+
seqPosIndexCache.set(MSA, cache);
|
|
1107
|
+
}
|
|
1108
|
+
let index = cache.get(rowName);
|
|
1109
|
+
if (!index) {
|
|
1110
|
+
const seq = MSA.getRow(rowName);
|
|
1111
|
+
if (!seq) {
|
|
1112
|
+
return undefined;
|
|
1113
|
+
}
|
|
1114
|
+
index = buildSeqPosIndex(seq);
|
|
1115
|
+
cache.set(rowName, index);
|
|
1116
|
+
}
|
|
1117
|
+
return index;
|
|
882
1118
|
},
|
|
883
1119
|
/**
|
|
884
1120
|
* #getter
|
|
1121
|
+
* every sequence the alignment holds, keyed by row name, whatever the
|
|
1122
|
+
* tree currently shows. `rows` is the rows on screen; this is the rows
|
|
1123
|
+
* that exist, and every lookup about a named row goes through it --
|
|
1124
|
+
* collapsing a clade hides rows, it does not delete their sequence
|
|
885
1125
|
*/
|
|
886
1126
|
get rowMap() {
|
|
887
|
-
|
|
1127
|
+
const MSA = this.MSA;
|
|
1128
|
+
return new Map(MSA?.getNames()
|
|
1129
|
+
.map(name => [name, MSA.getRow(name)])
|
|
1130
|
+
.filter(([, seq]) => !!seq));
|
|
888
1131
|
},
|
|
889
1132
|
/**
|
|
890
1133
|
* #getter
|
|
@@ -1009,10 +1252,13 @@ function stateModelFactory() {
|
|
|
1009
1252
|
get hierarchy() {
|
|
1010
1253
|
const r = this.root;
|
|
1011
1254
|
clusterLayout(r, this.totalHeight, self.treeWidth);
|
|
1012
|
-
|
|
1013
|
-
const max = maxLength(r);
|
|
1255
|
+
const max = this.rootToTipLength;
|
|
1014
1256
|
const k = max ? self.treeWidth / max : 0;
|
|
1015
|
-
|
|
1257
|
+
// the displayed root starts at x=0 whatever branch length it carries,
|
|
1258
|
+
// so its own length is subtracted here rather than zeroed on the parsed
|
|
1259
|
+
// node -- `root` hands out the cached parse, and writing to it made
|
|
1260
|
+
// showOnly shorten that branch for good
|
|
1261
|
+
setBrLength(r, -Math.max(r.data.length || 0, 0), k);
|
|
1016
1262
|
// for each collapsed clade, record the pixel x-position of its farthest
|
|
1017
1263
|
// tip so the renderer can draw a triangle spanning the branch-length
|
|
1018
1264
|
// extent of the hidden subtree
|
|
@@ -1038,10 +1284,32 @@ function stateModelFactory() {
|
|
|
1038
1284
|
},
|
|
1039
1285
|
/**
|
|
1040
1286
|
* #getter
|
|
1041
|
-
*
|
|
1287
|
+
* branch-length extent of the displayed tree, root to farthest tip, in
|
|
1288
|
+
* the tree's own units
|
|
1289
|
+
*/
|
|
1290
|
+
get rootToTipLength() {
|
|
1291
|
+
const r = this.root;
|
|
1292
|
+
return maxLength(r) - Math.max(r.data.length || 0, 0);
|
|
1293
|
+
},
|
|
1294
|
+
/**
|
|
1295
|
+
* #getter
|
|
1296
|
+
* x-position of the farthest tip in a phylogram, px. The layout scales
|
|
1297
|
+
* the longest root-to-tip path onto treeWidth, so that is where it lands
|
|
1298
|
+
* -- and 0 for a tree carrying no lengths at all, which draws as a
|
|
1299
|
+
* cladogram instead
|
|
1042
1300
|
*/
|
|
1043
1301
|
get maxBranchLength() {
|
|
1044
|
-
return
|
|
1302
|
+
return this.rootToTipLength ? self.treeWidth : 0;
|
|
1303
|
+
},
|
|
1304
|
+
/**
|
|
1305
|
+
* #getter
|
|
1306
|
+
* pixels per unit of branch length in the current phylogram layout, and 0
|
|
1307
|
+
* in cladogram mode, where the x-positions carry no length at all. The
|
|
1308
|
+
* scale bar over the tree is drawn from it.
|
|
1309
|
+
*/
|
|
1310
|
+
get pxPerBranchLength() {
|
|
1311
|
+
const max = maxLength(this.root);
|
|
1312
|
+
return this.showBranchLenEffective && max ? self.treeWidth / max : 0;
|
|
1045
1313
|
},
|
|
1046
1314
|
/**
|
|
1047
1315
|
* #getter
|
|
@@ -1054,7 +1322,7 @@ function stateModelFactory() {
|
|
|
1054
1322
|
* #getter
|
|
1055
1323
|
*/
|
|
1056
1324
|
get allBranchesLength0() {
|
|
1057
|
-
return
|
|
1325
|
+
return this.rootToTipLength === 0;
|
|
1058
1326
|
},
|
|
1059
1327
|
/**
|
|
1060
1328
|
* #getter
|
|
@@ -1076,605 +1344,905 @@ function stateModelFactory() {
|
|
|
1076
1344
|
/**
|
|
1077
1345
|
* #getter
|
|
1078
1346
|
*/
|
|
1079
|
-
get
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
// the import form
|
|
1085
|
-
return !!(self.data.msa || self.data.tree) && !self.error;
|
|
1347
|
+
get showMsaLetters() {
|
|
1348
|
+
return (self.drawMsaLetters &&
|
|
1349
|
+
self.rowHeight >= minLetterRowHeight &&
|
|
1350
|
+
self.colWidth >= minLetterColWidth &&
|
|
1351
|
+
self.colWidth > self.rowHeight / 2);
|
|
1086
1352
|
},
|
|
1087
1353
|
/**
|
|
1088
1354
|
* #getter
|
|
1089
1355
|
*/
|
|
1090
|
-
get
|
|
1091
|
-
return
|
|
1092
|
-
viewportSize: self.msaAreaWidth,
|
|
1093
|
-
viewportPos: -self.scrollX,
|
|
1094
|
-
blockSize: self.blockSize,
|
|
1095
|
-
mapSize: self.totalWidth,
|
|
1096
|
-
});
|
|
1356
|
+
get showTreeText() {
|
|
1357
|
+
return self.drawLabels && self.rowHeight >= minLetterRowHeight;
|
|
1097
1358
|
},
|
|
1359
|
+
}))
|
|
1360
|
+
.views(self => ({
|
|
1098
1361
|
/**
|
|
1099
1362
|
* #getter
|
|
1100
1363
|
*/
|
|
1101
|
-
get
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1364
|
+
get labelWidthMap() {
|
|
1365
|
+
const { showTreeText, leaves, treeMetadata } = self;
|
|
1366
|
+
// gated on the same condition the renderer draws labels under, so the
|
|
1367
|
+
// gutter labelsWidth reserves and the labels actually drawn cannot
|
|
1368
|
+
// disagree -- and so turning labels off hands their space to the tree.
|
|
1369
|
+
// Measured at a fixed reference size and scaled by labelWidthScale:
|
|
1370
|
+
// re-measuring every leaf on every vertical-zoom frame cost ~200ms on a
|
|
1371
|
+
// 50k-leaf tree
|
|
1372
|
+
return showTreeText
|
|
1373
|
+
? new Map(leaves.map(node => {
|
|
1374
|
+
const { name } = node.data;
|
|
1375
|
+
// `||`, matching renderTreeLabels: an empty genome falls back
|
|
1376
|
+
// to the row name, and measuring '' would size the gutter (and
|
|
1377
|
+
// the label's click target) to nothing
|
|
1378
|
+
const displayName = treeMetadata[name]?.genome || name;
|
|
1379
|
+
return [
|
|
1380
|
+
name,
|
|
1381
|
+
measureTextCanvas(displayName, labelReferenceFontSize),
|
|
1382
|
+
];
|
|
1383
|
+
}))
|
|
1384
|
+
: new Map();
|
|
1108
1385
|
},
|
|
1109
1386
|
/**
|
|
1110
1387
|
* #getter
|
|
1111
|
-
*
|
|
1112
|
-
*
|
|
1388
|
+
* factor turning a labelWidthMap entry into its width at the current
|
|
1389
|
+
* font size
|
|
1113
1390
|
*/
|
|
1114
|
-
get
|
|
1115
|
-
return
|
|
1116
|
-
|
|
1117
|
-
|
|
1391
|
+
get labelWidthScale() {
|
|
1392
|
+
return self.fontSize / labelReferenceFontSize;
|
|
1393
|
+
},
|
|
1394
|
+
get labelsWidth() {
|
|
1395
|
+
// a loop, not Math.max(...widths.values()): spreading a map of every
|
|
1396
|
+
// leaf passes one argument per row, and the argument limit is somewhere
|
|
1397
|
+
// around 125k -- so the bundled 230k-tip COVID tree threw
|
|
1398
|
+
// "RangeError: Maximum call stack size exceeded" out of a getter the
|
|
1399
|
+
// treeWidth autorun reads on load
|
|
1400
|
+
let max = 0;
|
|
1401
|
+
for (const width of this.labelWidthMap.values()) {
|
|
1402
|
+
if (width > max) {
|
|
1403
|
+
max = width;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
return max * this.labelWidthScale;
|
|
1118
1407
|
},
|
|
1119
|
-
}))
|
|
1120
|
-
.views(self => ({
|
|
1121
1408
|
/**
|
|
1122
1409
|
* #getter
|
|
1123
1410
|
*/
|
|
1124
|
-
get
|
|
1125
|
-
return self.
|
|
1411
|
+
get secondaryStructureConsensus() {
|
|
1412
|
+
return self.MSA?.secondaryStructureConsensus;
|
|
1126
1413
|
},
|
|
1127
1414
|
/**
|
|
1128
1415
|
* #getter
|
|
1129
1416
|
*/
|
|
1130
|
-
get
|
|
1131
|
-
return self.
|
|
1417
|
+
get seqConsensus() {
|
|
1418
|
+
return self.MSA?.seqConsensus;
|
|
1132
1419
|
},
|
|
1133
1420
|
/**
|
|
1134
1421
|
* #getter
|
|
1422
|
+
* the base pairs of the consensus secondary structure, as arcs. The WUSS
|
|
1423
|
+
* string is collapsed through the hidden columns before it is parsed, so
|
|
1424
|
+
* the pairs land in the same visible column space the text track does
|
|
1135
1425
|
*/
|
|
1136
|
-
get
|
|
1137
|
-
|
|
1426
|
+
get secondaryStructureArcs() {
|
|
1427
|
+
const { blanks, hideGapsEffective } = self;
|
|
1428
|
+
const ss = this.secondaryStructureConsensus;
|
|
1429
|
+
if (!ss) {
|
|
1430
|
+
return undefined;
|
|
1431
|
+
}
|
|
1432
|
+
return parseWuss(hideGapsEffective ? skipBlanks(blanks, ss) : ss).map(({ start, end, pseudoknot }) => ({
|
|
1433
|
+
start,
|
|
1434
|
+
end,
|
|
1435
|
+
color: pseudoknot ? PSEUDOKNOT_ARC : HELIX_ARC,
|
|
1436
|
+
}));
|
|
1138
1437
|
},
|
|
1139
1438
|
/**
|
|
1140
1439
|
* #getter
|
|
1141
|
-
* most-negative allowed scrollY, keeping the last row in view rather than
|
|
1142
|
-
* letting the whole alignment scroll off the top.
|
|
1143
1440
|
*/
|
|
1144
|
-
get
|
|
1145
|
-
|
|
1441
|
+
get adapterTrackModels() {
|
|
1442
|
+
const { MSA, hideGapsEffective, blanks } = self;
|
|
1443
|
+
const tracks = (MSA?.tracks ?? []).filter(t => !!t.data);
|
|
1444
|
+
if (tracks.length === 0) {
|
|
1445
|
+
// reading rowHeight up front made every zoom frame rebuild the track
|
|
1446
|
+
// list, and the canvases redraw on the track object they are handed
|
|
1447
|
+
// changing
|
|
1448
|
+
return [];
|
|
1449
|
+
}
|
|
1450
|
+
const { rowHeight } = self;
|
|
1451
|
+
return tracks.map(t => ({
|
|
1452
|
+
model: {
|
|
1453
|
+
...t,
|
|
1454
|
+
kind: 'text',
|
|
1455
|
+
data: hideGapsEffective ? skipBlanks(blanks, t.data) : t.data,
|
|
1456
|
+
height: rowHeight,
|
|
1457
|
+
},
|
|
1458
|
+
ReactComponent: TrackBlocks,
|
|
1459
|
+
}));
|
|
1146
1460
|
},
|
|
1147
1461
|
/**
|
|
1148
1462
|
* #getter
|
|
1463
|
+
* a data track's values or string, projected from its row's residues
|
|
1464
|
+
* onto alignment columns when it names a row
|
|
1149
1465
|
*/
|
|
1150
|
-
get
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1466
|
+
get columnTrackContent() {
|
|
1467
|
+
const { MSA, blanks, hideGapsEffective } = self;
|
|
1468
|
+
const width = MSA?.getWidth() ?? 0;
|
|
1469
|
+
const project = (track, items, fill) => {
|
|
1470
|
+
if (!track.row) {
|
|
1471
|
+
return items;
|
|
1472
|
+
}
|
|
1473
|
+
const out = Array.from({ length: width }, () => fill);
|
|
1474
|
+
const index = self.seqPosIndex(track.row);
|
|
1475
|
+
items.forEach((item, seqPos) => {
|
|
1476
|
+
const col = index?.[seqPos];
|
|
1477
|
+
if (col !== undefined) {
|
|
1478
|
+
out[col] = item;
|
|
1479
|
+
}
|
|
1480
|
+
});
|
|
1481
|
+
return out;
|
|
1482
|
+
};
|
|
1483
|
+
const skip = (items) => hideGapsEffective ? dropBlanks(blanks, items) : items;
|
|
1484
|
+
// an arc names two positions rather than one per column, so it takes
|
|
1485
|
+
// the same two steps the arrays take -- a row's residues onto columns,
|
|
1486
|
+
// then columns onto the visible ones -- as a lookup. visibleColsBefore,
|
|
1487
|
+
// not globalColToVisibleCol: an endpoint in a hidden column collapses
|
|
1488
|
+
// to where that column went instead of taking the whole arc with it
|
|
1489
|
+
const resolve = (track, pos) => {
|
|
1490
|
+
const col = track.row
|
|
1491
|
+
? self.seqPosIndex(track.row)?.[pos - 1]
|
|
1492
|
+
: pos - 1;
|
|
1493
|
+
if (col === undefined || col < 0 || col >= width) {
|
|
1494
|
+
return undefined;
|
|
1495
|
+
}
|
|
1496
|
+
return hideGapsEffective ? visibleColsBefore(blanks, col) : col;
|
|
1497
|
+
};
|
|
1498
|
+
return new Map(self.columnTracks.map(track => {
|
|
1499
|
+
if (track.kind === 'arc') {
|
|
1500
|
+
const arcs = (track.arcs ?? [])
|
|
1501
|
+
.map(arc => {
|
|
1502
|
+
const start = resolve(track, Math.min(arc.start, arc.end));
|
|
1503
|
+
const end = resolve(track, Math.max(arc.start, arc.end));
|
|
1504
|
+
return start !== undefined && end !== undefined && start < end
|
|
1505
|
+
? { start, end, color: arc.color }
|
|
1506
|
+
: undefined;
|
|
1507
|
+
})
|
|
1508
|
+
.filter(notEmpty);
|
|
1509
|
+
return [track.id, { arcs }];
|
|
1510
|
+
}
|
|
1511
|
+
if (track.kind === 'bar') {
|
|
1512
|
+
const max = track.max ?? 1;
|
|
1513
|
+
const values = skip(project(track, track.values ?? [], 0)).map(v => Math.min(1, Math.max(0, v / max)));
|
|
1514
|
+
return [track.id, { values }];
|
|
1515
|
+
}
|
|
1516
|
+
const data = skip(project(track, (track.data ?? '').split(''), ' '));
|
|
1517
|
+
return [track.id, { data: data.join('') }];
|
|
1518
|
+
}));
|
|
1155
1519
|
},
|
|
1156
1520
|
/**
|
|
1157
1521
|
* #getter
|
|
1158
1522
|
*/
|
|
1159
|
-
get
|
|
1160
|
-
|
|
1523
|
+
get columnTrackModels() {
|
|
1524
|
+
// read per kind, not up front: a text track is the only kind sized by
|
|
1525
|
+
// the row height, and reading it here rebuilt every data track on every
|
|
1526
|
+
// vertical zoom step
|
|
1527
|
+
const defaultHeight = (kind) => kind === 'bar'
|
|
1528
|
+
? self.conservationTrackHeight
|
|
1529
|
+
: kind === 'arc'
|
|
1530
|
+
? self.arcTrackHeight
|
|
1531
|
+
: self.rowHeight;
|
|
1532
|
+
return self.columnTracks.map(track => ({
|
|
1533
|
+
model: {
|
|
1534
|
+
id: track.id,
|
|
1535
|
+
name: track.name,
|
|
1536
|
+
kind: track.kind,
|
|
1537
|
+
height: self.columnTrackHeights[track.id] ??
|
|
1538
|
+
track.height ??
|
|
1539
|
+
defaultHeight(track.kind),
|
|
1540
|
+
// the spec has one `color`; bar and arc are separate track models
|
|
1541
|
+
// that read it under their own name
|
|
1542
|
+
barColor: track.color,
|
|
1543
|
+
arcColor: track.color,
|
|
1544
|
+
customColorScheme: track.colors,
|
|
1545
|
+
data: this.columnTrackContent.get(track.id)?.data,
|
|
1546
|
+
arcs: this.columnTrackContent.get(track.id)?.arcs,
|
|
1547
|
+
},
|
|
1548
|
+
ReactComponent: TrackBlocks,
|
|
1549
|
+
}));
|
|
1161
1550
|
},
|
|
1162
|
-
}))
|
|
1163
|
-
.actions(self => ({
|
|
1164
1551
|
/**
|
|
1165
|
-
* #
|
|
1552
|
+
* #getter
|
|
1166
1553
|
*/
|
|
1167
|
-
setDrawMsaLetters(arg) {
|
|
1168
|
-
self.drawMsaLetters = arg;
|
|
1169
|
-
},
|
|
1170
1554
|
/**
|
|
1171
|
-
* #
|
|
1555
|
+
* #getter
|
|
1556
|
+
* the consensus secondary structure as a track, when there is one. Its
|
|
1557
|
+
* own getter so the object keeps its identity across a zoom: the canvas
|
|
1558
|
+
* redraws on the track it is handed changing, and rebuilding these
|
|
1559
|
+
* alongside everything else made every zoom frame redraw every track
|
|
1172
1560
|
*/
|
|
1173
|
-
|
|
1174
|
-
|
|
1561
|
+
get basePairTrackModels() {
|
|
1562
|
+
const arcs = this.secondaryStructureArcs;
|
|
1563
|
+
return arcs?.length
|
|
1564
|
+
? [
|
|
1565
|
+
{
|
|
1566
|
+
model: {
|
|
1567
|
+
id: 'base-pairs',
|
|
1568
|
+
name: 'Base pairs',
|
|
1569
|
+
kind: 'arc',
|
|
1570
|
+
height: self.arcTrackHeight,
|
|
1571
|
+
arcs,
|
|
1572
|
+
},
|
|
1573
|
+
ReactComponent: TrackBlocks,
|
|
1574
|
+
},
|
|
1575
|
+
]
|
|
1576
|
+
: [];
|
|
1175
1577
|
},
|
|
1176
1578
|
/**
|
|
1177
|
-
* #
|
|
1178
|
-
*
|
|
1579
|
+
* #getter
|
|
1580
|
+
* the tracks computed from the alignment itself, which depend on their
|
|
1581
|
+
* own heights and on the alphabet -- and on nothing zoom changes
|
|
1179
1582
|
*/
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
self.
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1583
|
+
get computedTrackModels() {
|
|
1584
|
+
return [
|
|
1585
|
+
{
|
|
1586
|
+
id: 'conservation',
|
|
1587
|
+
name: 'Conservation',
|
|
1588
|
+
kind: 'bar',
|
|
1589
|
+
height: self.conservationTrackHeight,
|
|
1590
|
+
barColor: 'gray',
|
|
1591
|
+
},
|
|
1592
|
+
...(self.sequenceType === 'amino'
|
|
1593
|
+
? [
|
|
1594
|
+
{
|
|
1595
|
+
id: 'property-conservation',
|
|
1596
|
+
name: 'Property conservation',
|
|
1597
|
+
kind: 'bar',
|
|
1598
|
+
height: self.conservationTrackHeight,
|
|
1599
|
+
barColor: '#6a51a3',
|
|
1600
|
+
},
|
|
1601
|
+
]
|
|
1602
|
+
: []),
|
|
1603
|
+
{
|
|
1604
|
+
id: 'sequence-logo',
|
|
1605
|
+
name: 'Sequence logo',
|
|
1606
|
+
kind: 'logo',
|
|
1607
|
+
height: self.sequenceLogoTrackHeight,
|
|
1608
|
+
},
|
|
1609
|
+
// last, so it sits against the alignment it numbers
|
|
1610
|
+
{
|
|
1611
|
+
id: 'position-ruler',
|
|
1612
|
+
name: 'Position',
|
|
1613
|
+
kind: 'ruler',
|
|
1614
|
+
height: 20,
|
|
1615
|
+
},
|
|
1616
|
+
].map(model => ({ model, ReactComponent: TrackBlocks }));
|
|
1617
|
+
},
|
|
1618
|
+
get tracks() {
|
|
1619
|
+
return [
|
|
1620
|
+
...this.adapterTrackModels,
|
|
1621
|
+
...this.basePairTrackModels,
|
|
1622
|
+
...this.columnTrackModels,
|
|
1623
|
+
...this.computedTrackModels,
|
|
1624
|
+
];
|
|
1199
1625
|
},
|
|
1200
1626
|
/**
|
|
1201
|
-
* #
|
|
1202
|
-
* Calculate a neighbor joining tree from the current MSA using BLOSUM62 distances
|
|
1627
|
+
* #getter
|
|
1203
1628
|
*/
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
throw new Error('Need at least 2 sequences to build a tree');
|
|
1207
|
-
}
|
|
1208
|
-
const newickTree = calculateNeighborJoiningTree(self.rows);
|
|
1209
|
-
self.setTree(newickTree);
|
|
1629
|
+
get turnedOnTracks() {
|
|
1630
|
+
return this.tracks.filter(f => !trackIsOff(self.turnedOffTracks, f.model.id, f.model.defaultOff));
|
|
1210
1631
|
},
|
|
1211
1632
|
/**
|
|
1212
|
-
* #
|
|
1213
|
-
* restore the default column width and row height
|
|
1633
|
+
* #getter
|
|
1214
1634
|
*/
|
|
1215
|
-
|
|
1216
|
-
self.
|
|
1217
|
-
self.setRowHeight(defaultRowHeight);
|
|
1635
|
+
get showHorizontalScrollbar() {
|
|
1636
|
+
return overflows(self.totalWidth, self.msaAreaWidth);
|
|
1218
1637
|
},
|
|
1219
1638
|
/**
|
|
1220
|
-
* #
|
|
1639
|
+
* #method
|
|
1640
|
+
* Return a row-specific letter at a visible column, or undefined if gap.
|
|
1641
|
+
*
|
|
1642
|
+
* @param rowName - The name of the row
|
|
1643
|
+
* @param visibleCol - The visible column index (what the user sees on screen)
|
|
1644
|
+
* @returns The letter at that position, or undefined if it's a gap
|
|
1221
1645
|
*/
|
|
1222
|
-
|
|
1223
|
-
self.
|
|
1224
|
-
self.scrollX = clamp(self.scrollX, self.maxScrollX, 0);
|
|
1646
|
+
visibleColToRowLetter(rowName, visibleCol) {
|
|
1647
|
+
return self.rowMap.get(rowName)?.[this.visibleColToGlobalCol(visibleCol)];
|
|
1225
1648
|
},
|
|
1226
1649
|
/**
|
|
1227
|
-
* #
|
|
1650
|
+
* #method
|
|
1651
|
+
* Convert a visible column to a row-specific sequence position (0-based).
|
|
1652
|
+
* Returns undefined if the position is a gap in the sequence.
|
|
1653
|
+
*
|
|
1654
|
+
* PUBLIC API: this and the sibling coordinate converters
|
|
1655
|
+
* (visibleColToGlobalCol, seqPosToVisibleCol, globalColToVisibleCol,
|
|
1656
|
+
* seqPosToGlobalCol) are how a host translates between alignment columns
|
|
1657
|
+
* and a row's residue positions across gaps. Keep them stable.
|
|
1658
|
+
*
|
|
1659
|
+
* @param rowName - The name of the row
|
|
1660
|
+
* @param visibleCol - The visible column index
|
|
1661
|
+
* @returns The sequence position (0-based), or undefined if it's a gap
|
|
1228
1662
|
*/
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1663
|
+
visibleColToSeqPos(rowName, visibleCol) {
|
|
1664
|
+
// a binary search of the row's index, not a scan of the row: this
|
|
1665
|
+
// answers on every mouse move, and a 30k-column row scanned per event
|
|
1666
|
+
// is the whole frame
|
|
1667
|
+
return seqPosOfGlobalCol(self.seqPosIndex(rowName), this.visibleColToGlobalCol(visibleCol));
|
|
1232
1668
|
},
|
|
1233
1669
|
/**
|
|
1234
|
-
* #
|
|
1670
|
+
* #method
|
|
1671
|
+
* Convert a visible column to a row-specific sequence position (1-based).
|
|
1672
|
+
* Returns undefined if the position is a gap in the sequence.
|
|
1673
|
+
*
|
|
1674
|
+
* @param rowName - The name of the row
|
|
1675
|
+
* @param visibleCol - The visible column index
|
|
1676
|
+
* @returns The sequence position (1-based), or undefined if it's a gap
|
|
1235
1677
|
*/
|
|
1236
|
-
|
|
1237
|
-
|
|
1678
|
+
visibleColToSeqPosOneBased(rowName, visibleCol) {
|
|
1679
|
+
const val = this.visibleColToSeqPos(rowName, visibleCol);
|
|
1680
|
+
return val !== undefined ? val + 1 : undefined;
|
|
1238
1681
|
},
|
|
1239
1682
|
/**
|
|
1240
|
-
* #
|
|
1683
|
+
* #method
|
|
1684
|
+
* Convert a global column index to a visible column index.
|
|
1685
|
+
* Returns undefined if the column is hidden (in blanks).
|
|
1686
|
+
* This is the inverse of visibleColToGlobalCol.
|
|
1687
|
+
*
|
|
1688
|
+
* @param globalCol - The global column index in the full MSA
|
|
1689
|
+
* @returns The visible column index, or undefined if the column is hidden
|
|
1241
1690
|
*/
|
|
1242
|
-
|
|
1243
|
-
|
|
1691
|
+
globalColToVisibleCol(globalCol) {
|
|
1692
|
+
const { blanks, hideGapsEffective } = self;
|
|
1693
|
+
if (!hideGapsEffective) {
|
|
1694
|
+
return globalCol;
|
|
1695
|
+
}
|
|
1696
|
+
return globalColToVisibleCol(blanks, globalCol);
|
|
1244
1697
|
},
|
|
1245
1698
|
/**
|
|
1246
|
-
* #
|
|
1699
|
+
* #method
|
|
1700
|
+
* Convert a visible column index (what a mouse handler reports) back to a
|
|
1701
|
+
* column of the full alignment. Hidden columns shift everything to their
|
|
1702
|
+
* right, so a host that holds per-column data of its own has to make this
|
|
1703
|
+
* hop before indexing it.
|
|
1704
|
+
*
|
|
1705
|
+
* @param visibleCol - The visible column index
|
|
1706
|
+
* @returns The global column index in the full MSA
|
|
1247
1707
|
*/
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1708
|
+
visibleColToGlobalCol(visibleCol) {
|
|
1709
|
+
const { blanks, hideGapsEffective } = self;
|
|
1710
|
+
return hideGapsEffective
|
|
1711
|
+
? visibleColToGlobalCol(blanks, visibleCol)
|
|
1712
|
+
: visibleCol;
|
|
1253
1713
|
},
|
|
1254
1714
|
/**
|
|
1255
|
-
* #
|
|
1715
|
+
* #method
|
|
1716
|
+
* Convert a sequence position (ungapped) to a global column index.
|
|
1717
|
+
* Returns undefined for a row the alignment does not have -- answering
|
|
1718
|
+
* anyway is how a mistyped or stale row name came to highlight column 0.
|
|
1719
|
+
*
|
|
1720
|
+
* @param rowName - The name of the row
|
|
1721
|
+
* @param seqPos - The sequence position (0-based, ungapped)
|
|
1722
|
+
* @returns The global column index in the full MSA, or undefined
|
|
1256
1723
|
*/
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
}
|
|
1724
|
+
seqPosToGlobalCol(rowName, seqPos) {
|
|
1725
|
+
const seq = self.rowMap.get(rowName);
|
|
1726
|
+
if (seq === undefined) {
|
|
1727
|
+
return undefined;
|
|
1728
|
+
}
|
|
1729
|
+
const col = self.seqPosIndex(rowName)?.[seqPos];
|
|
1730
|
+
// past the end of the ungapped sequence: 0 for the degenerate all-gap
|
|
1731
|
+
// row, otherwise one past the last column
|
|
1732
|
+
return col ?? (seqPos === 0 ? 0 : seq.length);
|
|
1262
1733
|
},
|
|
1263
1734
|
/**
|
|
1264
|
-
* #
|
|
1265
|
-
*
|
|
1266
|
-
*
|
|
1267
|
-
*
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1270
|
-
*
|
|
1271
|
-
* Drives wheel/trackpad-pinch zoom.
|
|
1735
|
+
* #method
|
|
1736
|
+
* Convert a sequence position (ungapped) directly to a visible column index.
|
|
1737
|
+
* This combines seqPosToGlobalCol and globalColToVisibleCol.
|
|
1738
|
+
*
|
|
1739
|
+
* @param rowName - The name of the row
|
|
1740
|
+
* @param seqPos - The sequence position (0-based, ungapped)
|
|
1741
|
+
* @returns The visible column index, or undefined if the column is hidden
|
|
1272
1742
|
*/
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1743
|
+
seqPosToVisibleCol(rowName, seqPos) {
|
|
1744
|
+
const globalCol = this.seqPosToGlobalCol(rowName, seqPos);
|
|
1745
|
+
return globalCol === undefined
|
|
1746
|
+
? undefined
|
|
1747
|
+
: this.globalColToVisibleCol(globalCol);
|
|
1748
|
+
},
|
|
1749
|
+
/**
|
|
1750
|
+
* #getter
|
|
1751
|
+
* every reason a mapping is being ignored, so a host can say which. A
|
|
1752
|
+
* mapping outlives the alignment it was computed for; when the two no
|
|
1753
|
+
* longer agree the lookups have to refuse, and refusing invisibly is how
|
|
1754
|
+
* "there is no structure here" gets confused with "this data is stale".
|
|
1755
|
+
*/
|
|
1756
|
+
get residueMappingProblems() {
|
|
1757
|
+
const problems = [];
|
|
1758
|
+
for (const mapping of self.residueMappings) {
|
|
1759
|
+
const where = { row: mapping.row, structureId: mapping.structure.id };
|
|
1760
|
+
const residues = self.seqPosIndex(mapping.row)?.length;
|
|
1761
|
+
if (residues === undefined) {
|
|
1762
|
+
problems.push({
|
|
1763
|
+
...where,
|
|
1764
|
+
scope: 'mapping',
|
|
1765
|
+
reason: 'no such row in the alignment',
|
|
1766
|
+
});
|
|
1767
|
+
continue;
|
|
1768
|
+
}
|
|
1769
|
+
if (mapping.rowLength !== undefined &&
|
|
1770
|
+
mapping.rowLength !== residues) {
|
|
1771
|
+
problems.push({
|
|
1772
|
+
...where,
|
|
1773
|
+
scope: 'mapping',
|
|
1774
|
+
reason: `computed against a ${mapping.rowLength}-residue row; this one has ${residues}`,
|
|
1775
|
+
});
|
|
1776
|
+
continue;
|
|
1777
|
+
}
|
|
1778
|
+
const overrun = mapping.segments.find(segment => segment.rowEnd > residues || segment.rowStart < 1);
|
|
1779
|
+
if (overrun) {
|
|
1780
|
+
problems.push({
|
|
1781
|
+
...where,
|
|
1782
|
+
scope: 'mapping',
|
|
1783
|
+
reason: `segment ${overrun.rowStart}-${overrun.rowEnd} does not fit a ${residues}-residue row`,
|
|
1784
|
+
});
|
|
1785
|
+
continue;
|
|
1786
|
+
}
|
|
1787
|
+
for (const segment of mapping.segments) {
|
|
1788
|
+
if (!sameLength(segment)) {
|
|
1789
|
+
problems.push({
|
|
1790
|
+
...where,
|
|
1791
|
+
scope: 'segment',
|
|
1792
|
+
reason: `segment ${segment.rowStart}-${segment.rowEnd} maps to ${segment.structStart}-${segment.structEnd}, which is a different length`,
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
return problems;
|
|
1289
1798
|
},
|
|
1290
1799
|
/**
|
|
1291
|
-
* #
|
|
1800
|
+
* #getter
|
|
1801
|
+
* the mappings that still fit the loaded alignment. A row-level problem
|
|
1802
|
+
* takes the whole mapping out; a single malformed segment takes only
|
|
1803
|
+
* itself, since the rest of the mapping is still a claim about residues
|
|
1804
|
+
* that exist.
|
|
1292
1805
|
*/
|
|
1293
|
-
|
|
1294
|
-
this.
|
|
1806
|
+
get usableResidueMappings() {
|
|
1807
|
+
const unusable = new Set(this.residueMappingProblems
|
|
1808
|
+
.filter(problem => problem.scope === 'mapping')
|
|
1809
|
+
.map(problem => `${problem.row}\u0000${problem.structureId}`));
|
|
1810
|
+
return self.residueMappings
|
|
1811
|
+
.filter(m => !unusable.has(`${m.row}\u0000${m.structure.id}`))
|
|
1812
|
+
.map(m => ({
|
|
1813
|
+
...m,
|
|
1814
|
+
segments: m.segments.filter(sameLength),
|
|
1815
|
+
}));
|
|
1295
1816
|
},
|
|
1296
1817
|
/**
|
|
1297
|
-
* #
|
|
1298
|
-
*
|
|
1818
|
+
* #getter
|
|
1819
|
+
* the structures the loaded alignment has usable mappings onto. A row can
|
|
1820
|
+
* have several -- an experimental entry and a predicted model, say -- so
|
|
1821
|
+
* a host that means a particular one has to name it.
|
|
1299
1822
|
*/
|
|
1300
|
-
|
|
1301
|
-
|
|
1823
|
+
get mappedStructures() {
|
|
1824
|
+
return this.usableResidueMappings.map(m => ({
|
|
1825
|
+
row: m.row,
|
|
1826
|
+
structure: m.structure,
|
|
1827
|
+
}));
|
|
1302
1828
|
},
|
|
1303
1829
|
/**
|
|
1304
|
-
* #
|
|
1305
|
-
*
|
|
1306
|
-
*
|
|
1307
|
-
*
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1830
|
+
* #method
|
|
1831
|
+
* The structure residue a row residue is, or undefined. Refusing is the
|
|
1832
|
+
* point: the guess this replaces answered every query, with a wrong
|
|
1833
|
+
* residue when it did not know.
|
|
1834
|
+
*
|
|
1835
|
+
* It also refuses when the answer is not unique. A row commonly maps onto
|
|
1836
|
+
* several structures -- an experimental entry and two predicted models --
|
|
1837
|
+
* and returning whichever came first would be the same class of wrong,
|
|
1838
|
+
* quieter. Name one with `structureId`, or use `mappedStructures` to see
|
|
1839
|
+
* what there is.
|
|
1840
|
+
*
|
|
1841
|
+
* Positions are 1-based, as `residueMappings` and `highlights` are --
|
|
1842
|
+
* note that the column helpers above take 0-based ones.
|
|
1843
|
+
*
|
|
1844
|
+
* @param rowName - The alignment row
|
|
1845
|
+
* @param seqPos - Residue of that row, 1-based
|
|
1846
|
+
* @param structureId - Which structure, when the row maps onto several
|
|
1847
|
+
*/
|
|
1848
|
+
structureResidue(rowName, seqPos, structureId) {
|
|
1849
|
+
const hits = [];
|
|
1850
|
+
for (const mapping of this.usableResidueMappings) {
|
|
1851
|
+
if (mapping.row !== rowName) {
|
|
1852
|
+
continue;
|
|
1853
|
+
}
|
|
1854
|
+
if (structureId !== undefined &&
|
|
1855
|
+
mapping.structure.id !== structureId) {
|
|
1856
|
+
continue;
|
|
1857
|
+
}
|
|
1858
|
+
const segment = mapping.segments.find(seg => seqPos >= seg.rowStart && seqPos <= seg.rowEnd);
|
|
1859
|
+
if (segment) {
|
|
1860
|
+
const position = segment.structStart + (seqPos - segment.rowStart);
|
|
1861
|
+
hits.push({
|
|
1862
|
+
structure: mapping.structure,
|
|
1863
|
+
position,
|
|
1864
|
+
observed: !inRanges(mapping.unobserved, position),
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
return hits.length === 1 ? hits[0] : undefined;
|
|
1312
1869
|
},
|
|
1313
1870
|
/**
|
|
1314
|
-
* #
|
|
1315
|
-
*
|
|
1316
|
-
*
|
|
1317
|
-
*
|
|
1871
|
+
* #method
|
|
1872
|
+
* The row residue a structure residue is, the same lookup backwards, and
|
|
1873
|
+
* refusing on the same terms. `asymId` picks between mappings onto the
|
|
1874
|
+
* same entry, which a homodimer -- two rows, two chains, one id -- always
|
|
1875
|
+
* needs; without it such a lookup is ambiguous and gets nothing.
|
|
1876
|
+
*
|
|
1877
|
+
* @param structureId - The structure's id, as the mapping names it
|
|
1878
|
+
* @param position - Residue of that structure, 1-based label_seq_id
|
|
1879
|
+
* @param asymId - Which chain, when the id alone is ambiguous
|
|
1880
|
+
*/
|
|
1881
|
+
rowResidue(structureId, position, asymId) {
|
|
1882
|
+
const hits = [];
|
|
1883
|
+
for (const mapping of this.usableResidueMappings) {
|
|
1884
|
+
if (mapping.structure.id !== structureId) {
|
|
1885
|
+
continue;
|
|
1886
|
+
}
|
|
1887
|
+
if (asymId !== undefined && mapping.structure.asymId !== asymId) {
|
|
1888
|
+
continue;
|
|
1889
|
+
}
|
|
1890
|
+
const segment = mapping.segments.find(seg => position >= seg.structStart && position <= seg.structEnd);
|
|
1891
|
+
if (segment) {
|
|
1892
|
+
hits.push({
|
|
1893
|
+
rowName: mapping.row,
|
|
1894
|
+
seqPos: segment.rowStart + (position - segment.structStart),
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
return hits.length === 1 ? hits[0] : undefined;
|
|
1899
|
+
},
|
|
1900
|
+
}))
|
|
1901
|
+
.views(self => ({
|
|
1902
|
+
/**
|
|
1903
|
+
* #getter
|
|
1904
|
+
* the vertical space the alignment rows actually get: the widget height
|
|
1905
|
+
* less everything stacked above and below them -- the header, the tracks,
|
|
1906
|
+
* and the minimap when the columns overflow. Every consumer wants this
|
|
1907
|
+
* same subtraction, so there is one of it: blocksY, maxScrollY, the
|
|
1908
|
+
* vertical scrollbar and fitVertically all read it, and a second getter
|
|
1909
|
+
* that forgot the tracks is what put the last rows out of reach.
|
|
1318
1910
|
*/
|
|
1319
|
-
|
|
1320
|
-
|
|
1911
|
+
get msaAreaHeight() {
|
|
1912
|
+
return (self.height -
|
|
1913
|
+
(self.showHorizontalScrollbar ? self.minimapHeight : 0) -
|
|
1914
|
+
self.headerHeight -
|
|
1915
|
+
this.totalTrackAreaHeight);
|
|
1321
1916
|
},
|
|
1322
|
-
|
|
1323
|
-
|
|
1917
|
+
/**
|
|
1918
|
+
* #getter
|
|
1919
|
+
* total height of track area (px)
|
|
1920
|
+
*/
|
|
1921
|
+
get totalTrackAreaHeight() {
|
|
1922
|
+
return sum(self.turnedOnTracks.map(r => r.model.height));
|
|
1324
1923
|
},
|
|
1325
1924
|
/**
|
|
1326
|
-
*
|
|
1925
|
+
* one representative annotation per accession, which is what the legend,
|
|
1926
|
+
* the filter dialog and the palettes key off
|
|
1327
1927
|
*/
|
|
1328
|
-
|
|
1329
|
-
|
|
1928
|
+
get annotationTypes() {
|
|
1929
|
+
// first occurrence wins. The representative supplies only the name,
|
|
1930
|
+
// description and -- for ordinal segments -- the start that orders
|
|
1931
|
+
// them, and those agree across an accession's instances
|
|
1932
|
+
const types = new Map();
|
|
1933
|
+
for (const annot of self.annotations) {
|
|
1934
|
+
if (!types.has(annot.accession)) {
|
|
1935
|
+
types.set(annot.accession, annot);
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
return types;
|
|
1330
1939
|
},
|
|
1940
|
+
get filteredAnnotations() {
|
|
1941
|
+
return self.annotations.filter(r => !self.turnedOffFeatures.get(r.accession));
|
|
1942
|
+
},
|
|
1943
|
+
get annotationsByRow() {
|
|
1944
|
+
return groupBy(this.filteredAnnotations, r => r.id);
|
|
1945
|
+
},
|
|
1946
|
+
}))
|
|
1947
|
+
.views(self => ({
|
|
1331
1948
|
/**
|
|
1332
|
-
* #
|
|
1949
|
+
* #getter
|
|
1333
1950
|
*/
|
|
1334
|
-
|
|
1335
|
-
self.
|
|
1951
|
+
get showVerticalScrollbar() {
|
|
1952
|
+
return overflows(self.totalHeight, self.msaAreaHeight);
|
|
1336
1953
|
},
|
|
1954
|
+
}))
|
|
1955
|
+
.views(self => ({
|
|
1337
1956
|
/**
|
|
1338
|
-
* #
|
|
1957
|
+
* #getter
|
|
1339
1958
|
*/
|
|
1340
|
-
|
|
1341
|
-
|
|
1959
|
+
get dataInitialized() {
|
|
1960
|
+
// truthiness, not `!== ''`: these are types.maybe, and DataModel's
|
|
1961
|
+
// postProcessSnapshot drops a document over 50kb, so a restored session
|
|
1962
|
+
// that inlined a large alignment comes back `undefined` here -- which
|
|
1963
|
+
// `!== ''` reads as initialized and renders an empty view instead of
|
|
1964
|
+
// the import form
|
|
1965
|
+
return !!(self.data.msa || self.data.tree) && !self.error;
|
|
1342
1966
|
},
|
|
1343
1967
|
/**
|
|
1344
|
-
* #
|
|
1968
|
+
* #getter
|
|
1345
1969
|
*/
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1970
|
+
get blocksX() {
|
|
1971
|
+
return calculateBlocks({
|
|
1972
|
+
viewportSize: self.msaAreaWidth,
|
|
1973
|
+
viewportPos: -self.scrollX,
|
|
1974
|
+
blockSize: self.blockSize,
|
|
1975
|
+
mapSize: self.totalWidth,
|
|
1976
|
+
});
|
|
1350
1977
|
},
|
|
1351
1978
|
/**
|
|
1352
|
-
* #
|
|
1979
|
+
* #getter
|
|
1353
1980
|
*/
|
|
1354
|
-
|
|
1355
|
-
|
|
1981
|
+
get blocksY() {
|
|
1982
|
+
return calculateBlocks({
|
|
1983
|
+
viewportSize: self.msaAreaHeight,
|
|
1984
|
+
viewportPos: -self.scrollY,
|
|
1985
|
+
blockSize: self.blockSize,
|
|
1986
|
+
mapSize: self.totalHeight,
|
|
1987
|
+
});
|
|
1356
1988
|
},
|
|
1357
1989
|
}))
|
|
1358
1990
|
.views(self => ({
|
|
1359
1991
|
/**
|
|
1360
1992
|
* #getter
|
|
1361
1993
|
*/
|
|
1362
|
-
get
|
|
1363
|
-
|
|
1364
|
-
// gated on the same condition the renderer draws labels under, so the
|
|
1365
|
-
// gutter labelsWidth reserves and the labels actually drawn cannot
|
|
1366
|
-
// disagree -- and so turning labels off hands their space to the tree.
|
|
1367
|
-
// Measured at a fixed reference size and scaled by labelWidthScale:
|
|
1368
|
-
// re-measuring every leaf on every vertical-zoom frame cost ~200ms on a
|
|
1369
|
-
// 50k-leaf tree
|
|
1370
|
-
return showTreeText
|
|
1371
|
-
? new Map(leaves.map(node => {
|
|
1372
|
-
const { name } = node.data;
|
|
1373
|
-
// `||`, matching renderTreeLabels: an empty genome falls back
|
|
1374
|
-
// to the row name, and measuring '' would size the gutter (and
|
|
1375
|
-
// the label's click target) to nothing
|
|
1376
|
-
const displayName = treeMetadata[name]?.genome || name;
|
|
1377
|
-
return [
|
|
1378
|
-
name,
|
|
1379
|
-
measureTextCanvas(displayName, labelReferenceFontSize),
|
|
1380
|
-
];
|
|
1381
|
-
}))
|
|
1382
|
-
: new Map();
|
|
1994
|
+
get blocks2d() {
|
|
1995
|
+
return self.blocksY.flatMap(by => self.blocksX.map(bx => [bx, by]));
|
|
1383
1996
|
},
|
|
1384
1997
|
/**
|
|
1385
1998
|
* #getter
|
|
1386
|
-
* factor turning a labelWidthMap entry into its width at the current
|
|
1387
|
-
* font size
|
|
1388
1999
|
*/
|
|
1389
|
-
get
|
|
1390
|
-
return self.
|
|
1391
|
-
},
|
|
1392
|
-
get labelsWidth() {
|
|
1393
|
-
// a loop, not Math.max(...widths.values()): spreading a map of every
|
|
1394
|
-
// leaf passes one argument per row, and the argument limit is somewhere
|
|
1395
|
-
// around 125k -- so the bundled 230k-tip COVID tree threw
|
|
1396
|
-
// "RangeError: Maximum call stack size exceeded" out of a getter the
|
|
1397
|
-
// treeWidth autorun reads on load
|
|
1398
|
-
let max = 0;
|
|
1399
|
-
for (const width of this.labelWidthMap.values()) {
|
|
1400
|
-
if (width > max) {
|
|
1401
|
-
max = width;
|
|
1402
|
-
}
|
|
1403
|
-
}
|
|
1404
|
-
return max * this.labelWidthScale;
|
|
2000
|
+
get isLoading() {
|
|
2001
|
+
return self.loadingMSA || self.loadingTree;
|
|
1405
2002
|
},
|
|
1406
2003
|
/**
|
|
1407
2004
|
* #getter
|
|
1408
2005
|
*/
|
|
1409
|
-
get
|
|
1410
|
-
return self.
|
|
2006
|
+
get maxScrollX() {
|
|
2007
|
+
return Math.min(-self.totalWidth + (self.msaAreaWidth - 100), 0);
|
|
1411
2008
|
},
|
|
1412
2009
|
/**
|
|
1413
2010
|
* #getter
|
|
2011
|
+
* most-negative allowed scrollY, keeping the last row in view rather than
|
|
2012
|
+
* letting the whole alignment scroll off the top.
|
|
1414
2013
|
*/
|
|
1415
|
-
get
|
|
1416
|
-
return self.
|
|
2014
|
+
get maxScrollY() {
|
|
2015
|
+
return Math.min(-self.totalHeight + self.msaAreaHeight, 0);
|
|
1417
2016
|
},
|
|
2017
|
+
}))
|
|
2018
|
+
.actions(self => ({
|
|
1418
2019
|
/**
|
|
1419
|
-
* #
|
|
2020
|
+
* #action
|
|
1420
2021
|
*/
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
const tracks = MSA?.tracks ?? [];
|
|
1424
|
-
return tracks
|
|
1425
|
-
.filter(t => !!t.data)
|
|
1426
|
-
.map(t => ({
|
|
1427
|
-
model: {
|
|
1428
|
-
...t,
|
|
1429
|
-
kind: 'text',
|
|
1430
|
-
data: hideGapsEffective ? skipBlanks(blanks, t.data) : t.data,
|
|
1431
|
-
height: rowHeight,
|
|
1432
|
-
},
|
|
1433
|
-
ReactComponent: TrackBlocks,
|
|
1434
|
-
}));
|
|
2022
|
+
setDrawMsaLetters(arg) {
|
|
2023
|
+
self.drawMsaLetters = arg;
|
|
1435
2024
|
},
|
|
1436
2025
|
/**
|
|
1437
|
-
* #
|
|
1438
|
-
* a data track's values or string, projected from its row's residues
|
|
1439
|
-
* onto alignment columns when it names a row
|
|
2026
|
+
* #action
|
|
1440
2027
|
*/
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
2028
|
+
setScrollZoom(arg) {
|
|
2029
|
+
self.scrollZoom = arg;
|
|
2030
|
+
},
|
|
2031
|
+
/**
|
|
2032
|
+
* #action
|
|
2033
|
+
* set hovered tree node and its descendants
|
|
2034
|
+
*/
|
|
2035
|
+
setHoveredTreeNode(nodeId) {
|
|
2036
|
+
// the tree's mousemove handler calls this on every event, and both the
|
|
2037
|
+
// lookup and the write are expensive: `find` walks the whole hierarchy,
|
|
2038
|
+
// and a fresh object here invalidates hoveredRowIndices and redraws the
|
|
2039
|
+
// tree and MSA overlays. Re-hovering the same node is the common case
|
|
2040
|
+
if (nodeId === self.hoveredTreeNode?.nodeId) {
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
if (!nodeId) {
|
|
2044
|
+
self.hoveredTreeNode = undefined;
|
|
2045
|
+
return;
|
|
2046
|
+
}
|
|
2047
|
+
const node = find(self.hierarchy, n => n.data.id === nodeId);
|
|
2048
|
+
self.hoveredTreeNode = node
|
|
2049
|
+
? {
|
|
2050
|
+
nodeId,
|
|
2051
|
+
descendantNames: leaves(node).map(leaf => leaf.data.name),
|
|
1464
2052
|
}
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
2053
|
+
: undefined;
|
|
2054
|
+
},
|
|
2055
|
+
/**
|
|
2056
|
+
* #action
|
|
2057
|
+
* Calculate a neighbor joining tree from the current MSA using BLOSUM62
|
|
2058
|
+
* distances. Refuses above `maxNeighborJoiningRows`: the join loop is
|
|
2059
|
+
* cubic and runs on the main thread, so 800 rows is a ten-second freeze
|
|
2060
|
+
* with no progress and no cancel, and a tree that size wants a tool built
|
|
2061
|
+
* for it anyway.
|
|
2062
|
+
*/
|
|
2063
|
+
calculateNeighborJoiningTreeFromMSA() {
|
|
2064
|
+
// every sequence in the alignment, not the rows on screen: a collapsed
|
|
2065
|
+
// clade is a display state, and building the tree from what it leaves
|
|
2066
|
+
// showing drops the sequences it hides out of the result
|
|
2067
|
+
const rows = [...self.rowMap];
|
|
2068
|
+
if (rows.length < 2) {
|
|
2069
|
+
throw new Error('Need at least 2 sequences to build a tree');
|
|
2070
|
+
}
|
|
2071
|
+
if (rows.length > maxNeighborJoiningRows) {
|
|
2072
|
+
throw new Error(`Neighbor joining here is capped at ${maxNeighborJoiningRows} sequences and this alignment has ${rows.length}. Build the tree with FastTree or IQ-TREE and open it alongside the alignment: https://gmod.org/JBrowseMSA/tutorials/protein_family`);
|
|
2073
|
+
}
|
|
2074
|
+
this.replaceTree(calculateNeighborJoiningTree(rows));
|
|
1468
2075
|
},
|
|
1469
2076
|
/**
|
|
1470
|
-
* #
|
|
2077
|
+
* #action
|
|
2078
|
+
* swap in a different tree over the same alignment. Node ids are derived
|
|
2079
|
+
* from the path (node-0-0-1), so a `collapsed` or `showOnly` id held over
|
|
2080
|
+
* from the old tree matches a real node in the new one and folds whatever
|
|
2081
|
+
* happens to sit there -- the ids go with the tree they name.
|
|
1471
2082
|
*/
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
height: track.height ??
|
|
1479
|
-
(track.kind === 'bar'
|
|
1480
|
-
? self.conservationTrackHeight
|
|
1481
|
-
: self.rowHeight),
|
|
1482
|
-
barColor: track.color,
|
|
1483
|
-
customColorScheme: track.colors,
|
|
1484
|
-
data: this.columnTrackContent.get(track.id)?.data,
|
|
1485
|
-
},
|
|
1486
|
-
ReactComponent: TrackBlocks,
|
|
1487
|
-
}));
|
|
2083
|
+
replaceTree(newick) {
|
|
2084
|
+
transaction(() => {
|
|
2085
|
+
self.collapsed.clear();
|
|
2086
|
+
self.setShowOnly(undefined);
|
|
2087
|
+
self.setTree(newick);
|
|
2088
|
+
});
|
|
1488
2089
|
},
|
|
1489
2090
|
/**
|
|
1490
|
-
* #
|
|
2091
|
+
* #action
|
|
2092
|
+
* restore the default column width and row height
|
|
1491
2093
|
*/
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
name: 'Conservation',
|
|
1496
|
-
kind: 'bar',
|
|
1497
|
-
height: self.conservationTrackHeight,
|
|
1498
|
-
barColor: 'gray',
|
|
1499
|
-
};
|
|
1500
|
-
const propertyConservationTrack = {
|
|
1501
|
-
id: 'property-conservation',
|
|
1502
|
-
name: 'Property conservation',
|
|
1503
|
-
kind: 'bar',
|
|
1504
|
-
height: self.conservationTrackHeight,
|
|
1505
|
-
barColor: '#6a51a3',
|
|
1506
|
-
};
|
|
1507
|
-
const sequenceLogoTrack = {
|
|
1508
|
-
id: 'sequence-logo',
|
|
1509
|
-
name: 'Sequence logo',
|
|
1510
|
-
kind: 'logo',
|
|
1511
|
-
height: self.sequenceLogoTrackHeight,
|
|
1512
|
-
};
|
|
1513
|
-
return [
|
|
1514
|
-
...this.adapterTrackModels,
|
|
1515
|
-
...this.columnTrackModels,
|
|
1516
|
-
...[
|
|
1517
|
-
conservationTrack,
|
|
1518
|
-
...(self.sequenceType === 'amino'
|
|
1519
|
-
? [propertyConservationTrack]
|
|
1520
|
-
: []),
|
|
1521
|
-
sequenceLogoTrack,
|
|
1522
|
-
].map(model => ({ model, ReactComponent: TrackBlocks })),
|
|
1523
|
-
];
|
|
2094
|
+
resetZoom() {
|
|
2095
|
+
self.setColWidth(defaultColWidth);
|
|
2096
|
+
self.setRowHeight(defaultRowHeight);
|
|
1524
2097
|
},
|
|
1525
2098
|
/**
|
|
1526
|
-
* #
|
|
2099
|
+
* #action
|
|
1527
2100
|
*/
|
|
1528
|
-
|
|
1529
|
-
|
|
2101
|
+
zoomOutHorizontal() {
|
|
2102
|
+
self.colWidth = Math.max(minColWidth, Math.floor(self.colWidth * 0.75));
|
|
2103
|
+
self.scrollX = clamp(self.scrollX, self.maxScrollX, 0);
|
|
1530
2104
|
},
|
|
1531
2105
|
/**
|
|
1532
|
-
* #
|
|
2106
|
+
* #action
|
|
1533
2107
|
*/
|
|
1534
|
-
|
|
1535
|
-
|
|
2108
|
+
zoomInHorizontal() {
|
|
2109
|
+
self.colWidth = Math.min(maxCellSize, Math.ceil(self.colWidth * 1.5));
|
|
2110
|
+
self.scrollX = clamp(self.scrollX, self.maxScrollX, 0);
|
|
1536
2111
|
},
|
|
1537
2112
|
/**
|
|
1538
|
-
* #
|
|
1539
|
-
* Return a row-specific letter at a visible column, or undefined if gap.
|
|
1540
|
-
*
|
|
1541
|
-
* @param rowName - The name of the row
|
|
1542
|
-
* @param visibleCol - The visible column index (what the user sees on screen)
|
|
1543
|
-
* @returns The letter at that position, or undefined if it's a gap
|
|
2113
|
+
* #action
|
|
1544
2114
|
*/
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
return rowMap.get(rowName)?.[visibleColToGlobalCol(blanks, visibleCol)];
|
|
2115
|
+
zoomInVertical() {
|
|
2116
|
+
self.rowHeight = Math.min(maxCellSize, Math.ceil(self.rowHeight * 1.5));
|
|
1548
2117
|
},
|
|
1549
2118
|
/**
|
|
1550
|
-
* #
|
|
1551
|
-
* Convert a visible column to a row-specific sequence position (0-based).
|
|
1552
|
-
* Returns undefined if the position is a gap in the sequence.
|
|
1553
|
-
*
|
|
1554
|
-
* CROSS-REPO CONTRACT: this and the sibling coordinate converters
|
|
1555
|
-
* (seqPosToVisibleCol, globalColToVisibleCol, seqPosToGlobalCol) are used
|
|
1556
|
-
* by jbrowse-plugin-protein3d to translate between alignment columns and
|
|
1557
|
-
* structure/sequence residue positions across gaps. Keep them stable.
|
|
1558
|
-
*
|
|
1559
|
-
* @param rowName - The name of the row
|
|
1560
|
-
* @param visibleCol - The visible column index
|
|
1561
|
-
* @returns The sequence position (0-based), or undefined if it's a gap
|
|
2119
|
+
* #action
|
|
1562
2120
|
*/
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
2121
|
+
zoomOutVertical() {
|
|
2122
|
+
self.rowHeight = Math.max(minRowHeight, Math.floor(self.rowHeight * 0.75));
|
|
2123
|
+
},
|
|
2124
|
+
/**
|
|
2125
|
+
* #action
|
|
2126
|
+
*/
|
|
2127
|
+
zoomIn() {
|
|
2128
|
+
transaction(() => {
|
|
2129
|
+
this.zoomInHorizontal();
|
|
2130
|
+
this.zoomInVertical();
|
|
1569
2131
|
});
|
|
1570
2132
|
},
|
|
1571
2133
|
/**
|
|
1572
|
-
* #
|
|
1573
|
-
* Convert a visible column to a row-specific sequence position (1-based).
|
|
1574
|
-
* Returns undefined if the position is a gap in the sequence.
|
|
1575
|
-
*
|
|
1576
|
-
* @param rowName - The name of the row
|
|
1577
|
-
* @param visibleCol - The visible column index
|
|
1578
|
-
* @returns The sequence position (1-based), or undefined if it's a gap
|
|
2134
|
+
* #action
|
|
1579
2135
|
*/
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
2136
|
+
zoomOut() {
|
|
2137
|
+
transaction(() => {
|
|
2138
|
+
this.zoomOutHorizontal();
|
|
2139
|
+
this.zoomOutVertical();
|
|
2140
|
+
});
|
|
1583
2141
|
},
|
|
1584
2142
|
/**
|
|
1585
|
-
* #
|
|
1586
|
-
*
|
|
1587
|
-
*
|
|
1588
|
-
*
|
|
1589
|
-
*
|
|
1590
|
-
*
|
|
1591
|
-
*
|
|
2143
|
+
* #action
|
|
2144
|
+
* Smoothly zoom by a continuous scaleFactor. The column under the cursor
|
|
2145
|
+
* (offsetX/offsetY, px relative to the MSA area) stays anchored
|
|
2146
|
+
* horizontally. Vertically the anchor is biased toward the top: when the
|
|
2147
|
+
* alignment nearly fits the viewport, snap to y=0 rather than pinning a
|
|
2148
|
+
* random row under the cursor, with the bias fading out as the alignment
|
|
2149
|
+
* grows taller than the viewport (where cursor-anchoring is useful).
|
|
2150
|
+
* Drives wheel/trackpad-pinch zoom.
|
|
1592
2151
|
*/
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
2152
|
+
zoomToPos(scaleFactor, offsetX, offsetY) {
|
|
2153
|
+
transaction(() => {
|
|
2154
|
+
const colInView = (-self.scrollX + offsetX) / self.colWidth;
|
|
2155
|
+
const rowInView = (-self.scrollY + offsetY) / self.rowHeight;
|
|
2156
|
+
self.colWidth = clamp(self.colWidth * scaleFactor, minColWidth, maxCellSize);
|
|
2157
|
+
self.rowHeight = clamp(self.rowHeight * scaleFactor, minRowHeight, maxCellSize);
|
|
2158
|
+
self.scrollX = clamp(offsetX - colInView * self.colWidth, self.maxScrollX, 0);
|
|
2159
|
+
const anchoredScrollY = offsetY - rowInView * self.rowHeight;
|
|
2160
|
+
// maxScrollY is -(totalHeight - visibleMsaHeight) when the alignment
|
|
2161
|
+
// overflows, so -maxScrollY is exactly that overflow past the
|
|
2162
|
+
// scrollable MSA viewport (0 when it fits)
|
|
2163
|
+
const overflow = Math.max(0, -self.maxScrollY);
|
|
2164
|
+
const visibleHeight = self.totalHeight - overflow;
|
|
2165
|
+
const topBias = visibleHeight > 0 ? clamp(1 - overflow / visibleHeight, 0, 1) : 1;
|
|
2166
|
+
self.scrollY = clamp(anchoredScrollY * (1 - topBias), self.maxScrollY, 0);
|
|
2167
|
+
});
|
|
1599
2168
|
},
|
|
1600
2169
|
/**
|
|
1601
|
-
* #
|
|
1602
|
-
* Convert a sequence position (ungapped) to a global column index.
|
|
1603
|
-
*
|
|
1604
|
-
* @param rowName - The name of the row
|
|
1605
|
-
* @param seqPos - The sequence position (0-based, ungapped)
|
|
1606
|
-
* @returns The global column index in the full MSA
|
|
2170
|
+
* #action
|
|
1607
2171
|
*/
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
if (seq === undefined) {
|
|
1611
|
-
return 0;
|
|
1612
|
-
}
|
|
1613
|
-
const col = self.seqPosGlobalColIndex.get(rowName)?.[seqPos];
|
|
1614
|
-
// past the end of the ungapped sequence: 0 for the degenerate all-gap
|
|
1615
|
-
// row, otherwise one past the last column
|
|
1616
|
-
return col ?? (seqPos === 0 ? 0 : seq.length);
|
|
2172
|
+
doScrollY(deltaY) {
|
|
2173
|
+
this.setScrollY(self.scrollY + deltaY);
|
|
1617
2174
|
},
|
|
1618
2175
|
/**
|
|
1619
|
-
* #
|
|
1620
|
-
*
|
|
1621
|
-
|
|
2176
|
+
* #action
|
|
2177
|
+
* set scroll Y-offset (px), clamped to keep the alignment in view
|
|
2178
|
+
*/
|
|
2179
|
+
setScrollY(n) {
|
|
2180
|
+
self.scrollY = clamp(n, self.maxScrollY, 0);
|
|
2181
|
+
},
|
|
2182
|
+
/**
|
|
2183
|
+
* #action
|
|
2184
|
+
* Set the overlay annotations (an empty list clears them). Every source
|
|
2185
|
+
* funnels through here after its own adapter has flattened it:
|
|
2186
|
+
* InterProScan, GFF, user uploads, NCBI CDD.
|
|
1622
2187
|
*
|
|
1623
|
-
*
|
|
1624
|
-
*
|
|
1625
|
-
*
|
|
2188
|
+
* It does not touch `showDomains`. Loading used to force the overlay on,
|
|
2189
|
+
* and since a restored snapshot loads its GFF again on the way in, a link
|
|
2190
|
+
* shared with the overlay hidden reopened with it drawn.
|
|
1626
2191
|
*/
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
return this.globalColToVisibleCol(globalCol);
|
|
2192
|
+
setAnnotations(annotations) {
|
|
2193
|
+
self.annotations = annotations;
|
|
1630
2194
|
},
|
|
1631
|
-
}))
|
|
1632
|
-
.views(self => ({
|
|
1633
2195
|
/**
|
|
1634
|
-
* #
|
|
1635
|
-
*
|
|
2196
|
+
* #action
|
|
2197
|
+
* set the overlay from raw InterProScan results keyed by row name. Kept
|
|
2198
|
+
* for downstream plugins that hold the EBI wire format; new code should
|
|
2199
|
+
* adapt to Annotation[] and call setAnnotations.
|
|
1636
2200
|
*/
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
2201
|
+
setDomains(data) {
|
|
2202
|
+
this.setAnnotations(data ? interProScanToAnnotations(data) : []);
|
|
2203
|
+
},
|
|
2204
|
+
applyGFFText(gffText) {
|
|
2205
|
+
this.setAnnotations(gffToAnnotations(parseGFF(gffText)));
|
|
1641
2206
|
},
|
|
1642
2207
|
/**
|
|
1643
|
-
* #
|
|
1644
|
-
* total height of track area (px)
|
|
2208
|
+
* #action
|
|
1645
2209
|
*/
|
|
1646
|
-
|
|
1647
|
-
|
|
2210
|
+
doScrollX(deltaX) {
|
|
2211
|
+
this.setScrollX(self.scrollX + deltaX);
|
|
1648
2212
|
},
|
|
1649
2213
|
/**
|
|
1650
|
-
*
|
|
1651
|
-
* the filter dialog and the palettes key off
|
|
2214
|
+
* #action
|
|
1652
2215
|
*/
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
// description and -- for ordinal segments -- the start that orders
|
|
1656
|
-
// them, and those agree across an accession's instances
|
|
1657
|
-
const types = new Map();
|
|
1658
|
-
for (const annot of self.annotations) {
|
|
1659
|
-
if (!types.has(annot.accession)) {
|
|
1660
|
-
types.set(annot.accession, annot);
|
|
1661
|
-
}
|
|
1662
|
-
}
|
|
1663
|
-
return types;
|
|
2216
|
+
setScrollX(n) {
|
|
2217
|
+
self.scrollX = clamp(n, self.maxScrollX, 0);
|
|
1664
2218
|
},
|
|
1665
|
-
|
|
1666
|
-
|
|
2219
|
+
/**
|
|
2220
|
+
* #action
|
|
2221
|
+
*/
|
|
2222
|
+
setColumnTracks(tracks) {
|
|
2223
|
+
self.columnTracks.replace(tracks);
|
|
1667
2224
|
},
|
|
1668
|
-
|
|
1669
|
-
|
|
2225
|
+
/**
|
|
2226
|
+
* #action
|
|
2227
|
+
* replace the alignment<->structure correspondence (see docs/layers.md)
|
|
2228
|
+
*/
|
|
2229
|
+
setResidueMappings(mappings) {
|
|
2230
|
+
self.residueMappings.replace(mappings);
|
|
1670
2231
|
},
|
|
1671
|
-
}))
|
|
1672
|
-
.views(self => ({
|
|
1673
2232
|
/**
|
|
1674
|
-
* #
|
|
2233
|
+
* #action
|
|
1675
2234
|
*/
|
|
1676
|
-
|
|
1677
|
-
|
|
2235
|
+
toggleTrack(id) {
|
|
2236
|
+
// the stored value is "is off", so the current shown state is exactly
|
|
2237
|
+
// what the flipped entry should hold
|
|
2238
|
+
const defaultOff = self.MSA?.tracks.find(t => t.id === id)?.defaultOff;
|
|
2239
|
+
self.turnedOffTracks.set(id, !trackIsOff(self.turnedOffTracks, id, defaultOff));
|
|
2240
|
+
},
|
|
2241
|
+
/**
|
|
2242
|
+
* #action
|
|
2243
|
+
*/
|
|
2244
|
+
setStatus(status) {
|
|
2245
|
+
self.status = status;
|
|
1678
2246
|
},
|
|
1679
2247
|
}))
|
|
1680
2248
|
.views(self => ({
|
|
@@ -1746,7 +2314,7 @@ function stateModelFactory() {
|
|
|
1746
2314
|
*/
|
|
1747
2315
|
get visibleDomainTypes() {
|
|
1748
2316
|
return this.categoricalDomainTypes
|
|
1749
|
-
.filter(d => self.
|
|
2317
|
+
.filter(d => !self.turnedOffFeatures.get(d.accession))
|
|
1750
2318
|
.toSorted((a, b) => a.start - b.start);
|
|
1751
2319
|
},
|
|
1752
2320
|
/**
|
|
@@ -1773,9 +2341,14 @@ function stateModelFactory() {
|
|
|
1773
2341
|
// gap run -- and a residue whose column is itself hidden
|
|
1774
2342
|
// collapses onto the neighbouring boundary instead of dropping
|
|
1775
2343
|
// the band. A band whose every column is hidden spans nothing
|
|
1776
|
-
// and is left out.
|
|
1777
|
-
const
|
|
1778
|
-
const
|
|
2344
|
+
// and is left out, as is one naming a row the alignment lacks.
|
|
2345
|
+
const start = self.seqPosToGlobalCol(name, annotation.start - 1);
|
|
2346
|
+
const end = self.seqPosToGlobalCol(name, annotation.end - 1);
|
|
2347
|
+
if (start === undefined || end === undefined) {
|
|
2348
|
+
return undefined;
|
|
2349
|
+
}
|
|
2350
|
+
const startCol = visibleColsBefore(blanks, start);
|
|
2351
|
+
const endCol = visibleColsBefore(blanks, end + 1);
|
|
1779
2352
|
return endCol > startCol
|
|
1780
2353
|
? { annotation, startCol, endCol }
|
|
1781
2354
|
: undefined;
|
|
@@ -1809,11 +2382,16 @@ function stateModelFactory() {
|
|
|
1809
2382
|
get mouseOverDomains() {
|
|
1810
2383
|
const { mouseCol } = self;
|
|
1811
2384
|
const name = self.mouseOverRowName;
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
2385
|
+
if (name === undefined || mouseCol === undefined) {
|
|
2386
|
+
return noDomains;
|
|
2387
|
+
}
|
|
2388
|
+
const hits = (this.domainBands.get(name) ?? [])
|
|
2389
|
+
.filter(b => mouseCol >= b.startCol && mouseCol < b.endCol)
|
|
2390
|
+
.map(b => b.annotation);
|
|
2391
|
+
// the shared empty array, so moving the mouse across an alignment with
|
|
2392
|
+
// no annotations does not hand every canvas block a new value to
|
|
2393
|
+
// re-render on
|
|
2394
|
+
return hits.length > 0 ? hits : noDomains;
|
|
1817
2395
|
},
|
|
1818
2396
|
/**
|
|
1819
2397
|
* #getter
|
|
@@ -1868,12 +2446,18 @@ function stateModelFactory() {
|
|
|
1868
2446
|
* is dropped. Row names that match no row are ignored.
|
|
1869
2447
|
*/
|
|
1870
2448
|
get resolvedHighlights() {
|
|
1871
|
-
const { blanks, rowNamesSet,
|
|
2449
|
+
const { blanks, rowNamesSet, transientHighlights } = self;
|
|
1872
2450
|
const toVisible = (globalCol) => {
|
|
1873
2451
|
const visible = self.globalColToVisibleCol(globalCol);
|
|
1874
2452
|
return visible ?? visibleColsBefore(blanks, globalCol);
|
|
1875
2453
|
};
|
|
1876
|
-
|
|
2454
|
+
// the document's own highlights first, then what each owner is showing
|
|
2455
|
+
// right now, so a hover draws over a persisted band rather than under it
|
|
2456
|
+
const all = [
|
|
2457
|
+
...self.highlights,
|
|
2458
|
+
...Object.values(transientHighlights).flat(),
|
|
2459
|
+
];
|
|
2460
|
+
return all.flatMap(({ row, rows, start, end, label, color }) => {
|
|
1877
2461
|
const base = { label, color };
|
|
1878
2462
|
if (rows) {
|
|
1879
2463
|
const rowIndices = rows
|
|
@@ -1887,11 +2471,13 @@ function stateModelFactory() {
|
|
|
1887
2471
|
let startGlobal = start - 1;
|
|
1888
2472
|
let endGlobal = end - 1;
|
|
1889
2473
|
if (row !== undefined) {
|
|
1890
|
-
|
|
2474
|
+
const rowStart = self.seqPosToGlobalCol(row, start - 1);
|
|
2475
|
+
const rowEnd = self.seqPosToGlobalCol(row, end - 1);
|
|
2476
|
+
if (rowStart === undefined || rowEnd === undefined) {
|
|
1891
2477
|
return [];
|
|
1892
2478
|
}
|
|
1893
|
-
startGlobal =
|
|
1894
|
-
endGlobal =
|
|
2479
|
+
startGlobal = rowStart;
|
|
2480
|
+
endGlobal = rowEnd;
|
|
1895
2481
|
}
|
|
1896
2482
|
const startCol = toVisible(startGlobal);
|
|
1897
2483
|
const endVisible = self.globalColToVisibleCol(endGlobal);
|
|
@@ -1961,12 +2547,24 @@ function stateModelFactory() {
|
|
|
1961
2547
|
setConservationTrackHeight(arg) {
|
|
1962
2548
|
self.conservationTrackHeight = arg;
|
|
1963
2549
|
},
|
|
2550
|
+
/**
|
|
2551
|
+
* #action
|
|
2552
|
+
*/
|
|
2553
|
+
setColumnTrackHeight(id, height) {
|
|
2554
|
+
self.columnTrackHeights = { ...self.columnTrackHeights, [id]: height };
|
|
2555
|
+
},
|
|
1964
2556
|
/**
|
|
1965
2557
|
* #action
|
|
1966
2558
|
*/
|
|
1967
2559
|
setSequenceLogoTrackHeight(arg) {
|
|
1968
2560
|
self.sequenceLogoTrackHeight = arg;
|
|
1969
2561
|
},
|
|
2562
|
+
/**
|
|
2563
|
+
* #action
|
|
2564
|
+
*/
|
|
2565
|
+
setArcTrackHeight(arg) {
|
|
2566
|
+
self.arcTrackHeight = arg;
|
|
2567
|
+
},
|
|
1970
2568
|
/**
|
|
1971
2569
|
* #action
|
|
1972
2570
|
* Return to the import form: every property off `preservedOnReset`
|
|
@@ -1975,10 +2573,14 @@ function stateModelFactory() {
|
|
|
1975
2573
|
* reach are cleared by hand.
|
|
1976
2574
|
*/
|
|
1977
2575
|
reset() {
|
|
2576
|
+
self.resetCount++;
|
|
2577
|
+
self.clearWarnings();
|
|
2578
|
+
self.setStatus(undefined);
|
|
1978
2579
|
applySnapshot(self, Object.fromEntries(Object.entries(getSnapshot(self)).filter(([key]) => preservedOnReset.has(key))));
|
|
1979
2580
|
self.setError(undefined);
|
|
1980
2581
|
self.setAnnotations([]);
|
|
1981
2582
|
self.setHighlightedColumns(undefined);
|
|
2583
|
+
self.transientHighlights = {};
|
|
1982
2584
|
self.setMousePos(undefined, undefined);
|
|
1983
2585
|
self.setMouseClickPos(undefined, undefined);
|
|
1984
2586
|
self.setHoveredTreeNode(undefined);
|
|
@@ -1992,20 +2594,35 @@ function stateModelFactory() {
|
|
|
1992
2594
|
const blob = new Blob([html], { type: 'image/svg+xml' });
|
|
1993
2595
|
saveAs(blob, exportFileName(self.msaFilehandle, 'svg'));
|
|
1994
2596
|
},
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
2597
|
+
/**
|
|
2598
|
+
* #action
|
|
2599
|
+
* draw this annotation type, or stop drawing it. Only the "stop" is
|
|
2600
|
+
* recorded -- see `turnedOffFeatures`
|
|
2601
|
+
*/
|
|
2602
|
+
setFilter(accession, shown) {
|
|
2603
|
+
if (shown) {
|
|
2604
|
+
self.turnedOffFeatures.delete(accession);
|
|
2605
|
+
}
|
|
2606
|
+
else {
|
|
2607
|
+
self.turnedOffFeatures.set(accession, true);
|
|
1998
2608
|
}
|
|
1999
|
-
},
|
|
2000
|
-
setFilter(arg, flag) {
|
|
2001
|
-
self.featureFilters.set(arg, flag);
|
|
2002
2609
|
},
|
|
2003
2610
|
/**
|
|
2004
2611
|
* #action
|
|
2005
2612
|
*/
|
|
2006
2613
|
fit() {
|
|
2007
|
-
|
|
2008
|
-
|
|
2614
|
+
// Each direction's viewport depends on the other's result: fitting the
|
|
2615
|
+
// rows while the columns still overflow measures against a height the
|
|
2616
|
+
// minimap is taking, and fitting the columns while the rows still
|
|
2617
|
+
// overflow measures against a width the vertical scrollbar is taking.
|
|
2618
|
+
// A second pass measures against the geometry the first pass produced,
|
|
2619
|
+
// which is the one the reader ends up looking at.
|
|
2620
|
+
transaction(() => {
|
|
2621
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
2622
|
+
this.fitHorizontally();
|
|
2623
|
+
this.fitVertically();
|
|
2624
|
+
}
|
|
2625
|
+
});
|
|
2009
2626
|
},
|
|
2010
2627
|
/**
|
|
2011
2628
|
* #action
|
|
@@ -2035,11 +2652,6 @@ function stateModelFactory() {
|
|
|
2035
2652
|
if (self.highlightColumns?.length) {
|
|
2036
2653
|
self.setHighlightedColumns(self.highlightColumns);
|
|
2037
2654
|
}
|
|
2038
|
-
addDisposer(self, autorun(() => {
|
|
2039
|
-
for (const key of self.annotationTypes.keys()) {
|
|
2040
|
-
this.initFilter(key);
|
|
2041
|
-
}
|
|
2042
|
-
}));
|
|
2043
2655
|
// track the live device pixel ratio so canvas backing stores re-scale
|
|
2044
2656
|
// when the window moves between monitors or the browser zooms. The
|
|
2045
2657
|
// matchMedia query is pinned to the current ratio, so each change
|
|
@@ -2066,29 +2678,46 @@ function stateModelFactory() {
|
|
|
2066
2678
|
* Every loader carries a generation guard: the filehandle can change
|
|
2067
2679
|
* mid-fetch (a second file picked while the first is still in flight),
|
|
2068
2680
|
* and the slower earlier request must not clobber the data, status, or
|
|
2069
|
-
* loading flag belonging to the newer one.
|
|
2681
|
+
* loading flag belonging to the newer one. A superseded or cleared
|
|
2682
|
+
* request is also aborted, rather than left downloading a file nothing
|
|
2683
|
+
* is waiting for, and it gives back the status line it was writing --
|
|
2684
|
+
* a reset() mid-download used to leave "Downloading file" and a Cancel
|
|
2685
|
+
* button behind on the import form.
|
|
2070
2686
|
*
|
|
2071
2687
|
* `clearFilehandle` serves two purposes for the loaders that pass it.
|
|
2072
2688
|
* A local file has no URL to refetch from, so the handle is dropped
|
|
2073
2689
|
* once its bytes are in the model; and a fetch the user cancels drops
|
|
2074
2690
|
* it too, returning the view to the import form rather than leaving a
|
|
2075
2691
|
* stuck spinner.
|
|
2692
|
+
*
|
|
2693
|
+
* `what` names the layer in a failure message. An optional layer
|
|
2694
|
+
* (annotations, row metadata) that fails to load is a warning: it is
|
|
2695
|
+
* not worth replacing an alignment the reader is looking at with an
|
|
2696
|
+
* error screen over a decorative file.
|
|
2076
2697
|
*/
|
|
2077
|
-
const loadOnFilehandleChange = ({ getFilehandle, onLoad, setLoading, clearFilehandle, }) => {
|
|
2698
|
+
const loadOnFilehandleChange = ({ what, getFilehandle, onLoad, setLoading, clearFilehandle, optional = false, }) => {
|
|
2078
2699
|
let generation = 0;
|
|
2700
|
+
let cancelInFlight;
|
|
2701
|
+
addDisposer(self, () => {
|
|
2702
|
+
cancelInFlight?.();
|
|
2703
|
+
});
|
|
2079
2704
|
addDisposer(self, autorun(async () => {
|
|
2080
2705
|
const filehandle = getFilehandle();
|
|
2081
|
-
// a cleared filehandle bumps the generation too, so a reset()
|
|
2082
|
-
// mid-download invalidates the in-flight fetch instead of letting
|
|
2083
|
-
// its onLoad land on the emptied model. That also orphans the
|
|
2084
|
-
// invalidated run's `finally`, so the loading flag is cleared
|
|
2085
|
-
// here on its behalf
|
|
2086
2706
|
const current = ++generation;
|
|
2707
|
+
cancelInFlight?.();
|
|
2708
|
+
cancelInFlight = undefined;
|
|
2087
2709
|
if (!filehandle) {
|
|
2088
2710
|
setLoading?.(false);
|
|
2089
2711
|
return;
|
|
2090
2712
|
}
|
|
2091
|
-
const isCurrent = () => current === generation;
|
|
2713
|
+
const isCurrent = () => current === generation && isAlive(self);
|
|
2714
|
+
const controller = new AbortController();
|
|
2715
|
+
cancelInFlight = () => {
|
|
2716
|
+
controller.abort();
|
|
2717
|
+
if (isAlive(self)) {
|
|
2718
|
+
self.setStatus(undefined);
|
|
2719
|
+
}
|
|
2720
|
+
};
|
|
2092
2721
|
try {
|
|
2093
2722
|
setLoading?.(true);
|
|
2094
2723
|
self.setError(undefined);
|
|
@@ -2096,7 +2725,7 @@ function stateModelFactory() {
|
|
|
2096
2725
|
if (isCurrent()) {
|
|
2097
2726
|
self.setStatus(status);
|
|
2098
2727
|
}
|
|
2099
|
-
});
|
|
2728
|
+
}, { controller });
|
|
2100
2729
|
if (isCurrent()) {
|
|
2101
2730
|
transaction(() => {
|
|
2102
2731
|
onLoad(text);
|
|
@@ -2113,18 +2742,25 @@ function stateModelFactory() {
|
|
|
2113
2742
|
}
|
|
2114
2743
|
else {
|
|
2115
2744
|
console.error(e);
|
|
2116
|
-
|
|
2745
|
+
if (optional) {
|
|
2746
|
+
self.addWarning(`The ${what} did not load: ${e}`);
|
|
2747
|
+
}
|
|
2748
|
+
else {
|
|
2749
|
+
self.setError(e);
|
|
2750
|
+
}
|
|
2117
2751
|
}
|
|
2118
2752
|
}
|
|
2119
2753
|
}
|
|
2120
2754
|
finally {
|
|
2121
2755
|
if (isCurrent()) {
|
|
2756
|
+
cancelInFlight = undefined;
|
|
2122
2757
|
setLoading?.(false);
|
|
2123
2758
|
}
|
|
2124
2759
|
}
|
|
2125
2760
|
}));
|
|
2126
2761
|
};
|
|
2127
2762
|
loadOnFilehandleChange({
|
|
2763
|
+
what: 'tree',
|
|
2128
2764
|
getFilehandle: () => self.treeFilehandle,
|
|
2129
2765
|
onLoad: text => {
|
|
2130
2766
|
self.setTree(text);
|
|
@@ -2139,35 +2775,54 @@ function stateModelFactory() {
|
|
|
2139
2775
|
// treeMetadata is decorative and has no import-form step of its own, so
|
|
2140
2776
|
// it keeps no loading flag and nothing to return to on cancel
|
|
2141
2777
|
loadOnFilehandleChange({
|
|
2778
|
+
what: 'row metadata',
|
|
2779
|
+
optional: true,
|
|
2142
2780
|
getFilehandle: () => self.treeMetadataFilehandle,
|
|
2143
2781
|
onLoad: text => {
|
|
2144
2782
|
self.setTreeMetadata(text);
|
|
2145
2783
|
},
|
|
2146
2784
|
});
|
|
2147
|
-
// autorun parses inline gff text from data.gff
|
|
2785
|
+
// autorun parses inline gff text from data.gff. Text that goes away
|
|
2786
|
+
// takes its annotations with it -- setData with a new alignment and no
|
|
2787
|
+
// gff used to leave the previous file's annotations drawn over it --
|
|
2788
|
+
// while annotations a host set directly are left alone, which is why
|
|
2789
|
+
// this tracks what it applied instead of reading the current list
|
|
2790
|
+
let appliedGFF = false;
|
|
2148
2791
|
addDisposer(self, autorun(() => {
|
|
2149
2792
|
const gffText = self.data.gff;
|
|
2150
2793
|
if (gffText) {
|
|
2151
2794
|
try {
|
|
2152
2795
|
self.applyGFFText(gffText);
|
|
2796
|
+
appliedGFF = true;
|
|
2153
2797
|
}
|
|
2154
2798
|
catch (e) {
|
|
2799
|
+
// a malformed overlay is not worth replacing the alignment
|
|
2800
|
+
// with an error screen
|
|
2155
2801
|
console.error(e);
|
|
2156
|
-
self.
|
|
2802
|
+
self.addWarning(`The annotations did not parse: ${e}`);
|
|
2157
2803
|
}
|
|
2158
2804
|
}
|
|
2805
|
+
else if (appliedGFF) {
|
|
2806
|
+
appliedGFF = false;
|
|
2807
|
+
self.setAnnotations([]);
|
|
2808
|
+
}
|
|
2159
2809
|
}));
|
|
2160
|
-
// gffFilehandle carries overlay annotations
|
|
2810
|
+
// gffFilehandle carries overlay annotations. It loads into data.gff and
|
|
2811
|
+
// the autorun above parses it, so there is one parse path whether the
|
|
2812
|
+
// text arrived from a file or from a snapshot
|
|
2161
2813
|
loadOnFilehandleChange({
|
|
2814
|
+
what: 'annotations',
|
|
2815
|
+
optional: true,
|
|
2162
2816
|
getFilehandle: () => self.gffFilehandle,
|
|
2163
2817
|
onLoad: text => {
|
|
2164
|
-
self.
|
|
2818
|
+
self.setGFF(text);
|
|
2165
2819
|
},
|
|
2166
2820
|
clearFilehandle: () => {
|
|
2167
2821
|
self.setGFFFilehandle(undefined);
|
|
2168
2822
|
},
|
|
2169
2823
|
});
|
|
2170
2824
|
loadOnFilehandleChange({
|
|
2825
|
+
what: 'alignment',
|
|
2171
2826
|
getFilehandle: () => self.msaFilehandle,
|
|
2172
2827
|
onLoad: text => {
|
|
2173
2828
|
self.setMSA(text);
|
|
@@ -2220,9 +2875,23 @@ function stateModelFactory() {
|
|
|
2220
2875
|
self.setTreeAreaWidth(self.labelsWidth + self.marginLeft + 12);
|
|
2221
2876
|
}
|
|
2222
2877
|
}));
|
|
2223
|
-
//
|
|
2878
|
+
// treeWidth trails the tree area, less whatever the labels take out of
|
|
2879
|
+
// it. A width that arrived with the snapshot is left alone until the
|
|
2880
|
+
// tree area itself moves: a host that opens a view with a narrow tree
|
|
2881
|
+
// beside wide labels -- jbrowse-plugin-msaview does, at treeWidth 100
|
|
2882
|
+
// in a 200px area -- had its value overwritten on the first frame, and
|
|
2883
|
+
// a restored session came back re-derived rather than as it was left.
|
|
2884
|
+
// A getter cannot do this: labelsWidth is measured off the leaves,
|
|
2885
|
+
// which are laid out against treeWidth.
|
|
2886
|
+
let pinnedAreaWidth = self.treeWidth === defaultTreeWidth ? undefined : self.treeAreaWidth;
|
|
2224
2887
|
addDisposer(self, autorun(() => {
|
|
2225
|
-
|
|
2888
|
+
const areaWidth = self.treeAreaWidth;
|
|
2889
|
+
const labelsWidth = self.labelsWidth;
|
|
2890
|
+
if (pinnedAreaWidth === areaWidth) {
|
|
2891
|
+
return;
|
|
2892
|
+
}
|
|
2893
|
+
pinnedAreaWidth = undefined;
|
|
2894
|
+
self.setTreeWidth(Math.max(50, areaWidth - labelsWidth - 10 - self.marginLeft));
|
|
2226
2895
|
}));
|
|
2227
2896
|
},
|
|
2228
2897
|
}))
|