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/src/model.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
applySnapshot,
|
|
7
7
|
cast,
|
|
8
8
|
getSnapshot,
|
|
9
|
+
isAlive,
|
|
9
10
|
types,
|
|
10
11
|
} from '@jbrowse/mobx-state-tree'
|
|
11
12
|
import { colord } from 'colord'
|
|
@@ -40,8 +41,11 @@ import {
|
|
|
40
41
|
defaultShowDomainLegend,
|
|
41
42
|
defaultShowDomains,
|
|
42
43
|
defaultSubFeatureRows,
|
|
44
|
+
defaultTreeWidth,
|
|
43
45
|
labelReferenceFontSize,
|
|
44
46
|
maxCellSize,
|
|
47
|
+
maxInlineSnapshotBytes,
|
|
48
|
+
maxNeighborJoiningRows,
|
|
45
49
|
minColWidth,
|
|
46
50
|
minLetterColWidth,
|
|
47
51
|
minLetterRowHeight,
|
|
@@ -59,11 +63,9 @@ import {
|
|
|
59
63
|
collapse,
|
|
60
64
|
collapsedSubtreeMaxLength,
|
|
61
65
|
find,
|
|
62
|
-
findMaxBranchLen,
|
|
63
66
|
forEachDescendant,
|
|
64
67
|
hierarchy,
|
|
65
68
|
leaves,
|
|
66
|
-
links,
|
|
67
69
|
maxLength,
|
|
68
70
|
setBrLength,
|
|
69
71
|
sort,
|
|
@@ -79,8 +81,8 @@ import { parseAsn1 } from './parseAsn1.ts'
|
|
|
79
81
|
import { calculatePropertyConservation } from './propertyConservation.ts'
|
|
80
82
|
import {
|
|
81
83
|
globalColToVisibleCol,
|
|
84
|
+
seqPosOfGlobalCol,
|
|
82
85
|
visibleColToGlobalCol,
|
|
83
|
-
visibleColToSeqPosForRow,
|
|
84
86
|
visibleColsBefore,
|
|
85
87
|
} from './rowCoordinateCalculations.ts'
|
|
86
88
|
import { buildSeqPosIndex } from './seqPosToGlobalCol.ts'
|
|
@@ -94,18 +96,26 @@ import {
|
|
|
94
96
|
transform,
|
|
95
97
|
} from './util.ts'
|
|
96
98
|
import { saveAs } from './vendor/fileSaver.ts'
|
|
99
|
+
import { parseWuss } from './wuss.ts'
|
|
97
100
|
|
|
98
101
|
import type { HierarchyNode } from './hierarchy.ts'
|
|
99
102
|
import type { ExportSvgOptions } from './renderToSvg.tsx'
|
|
100
103
|
import type {
|
|
101
104
|
Annotation,
|
|
105
|
+
Arc,
|
|
102
106
|
BasicTrack,
|
|
107
|
+
ResidueMappingProblem,
|
|
103
108
|
ColumnTrackSpec,
|
|
104
109
|
DomainBand,
|
|
105
110
|
Highlight,
|
|
106
111
|
NodeWithIds,
|
|
107
112
|
NodeWithIdsAndLength,
|
|
113
|
+
ResidueMapping,
|
|
114
|
+
ResidueSegment,
|
|
108
115
|
ResolvedHighlight,
|
|
116
|
+
RowResidue,
|
|
117
|
+
StructureResidue,
|
|
118
|
+
UnshareableData,
|
|
109
119
|
} from './types.ts'
|
|
110
120
|
import type { FileLocation as FileLocationType } from '@jbrowse/core/util/types'
|
|
111
121
|
import type { Instance } from '@jbrowse/mobx-state-tree'
|
|
@@ -121,16 +131,22 @@ function parseTreeText(text: string) {
|
|
|
121
131
|
// Tracks that start hidden. The sequence logo answers a narrower question than
|
|
122
132
|
// conservation does and costs three times the vertical space, so it waits to be
|
|
123
133
|
// asked for.
|
|
124
|
-
const defaultOffTracks = new Set(['sequence-logo'])
|
|
134
|
+
const defaultOffTracks = new Set(['sequence-logo', 'position-ruler'])
|
|
135
|
+
|
|
136
|
+
// base-pair arcs: one color for the nested helices, one for a pseudoknot, whose
|
|
137
|
+
// whole point is that it crosses them
|
|
138
|
+
const HELIX_ARC = '#4e79a7'
|
|
139
|
+
const PSEUDOKNOT_ARC = '#e15759'
|
|
125
140
|
|
|
126
141
|
// a data track over this size stays in the live model but leaves the snapshot,
|
|
127
142
|
// the same rule DataModel applies to an inline document
|
|
128
|
-
|
|
143
|
+
function columnTrackSizes(tracks?: readonly ColumnTrackSpec[]) {
|
|
144
|
+
return (tracks ?? []).map(t => JSON.stringify(t).length)
|
|
145
|
+
}
|
|
129
146
|
|
|
130
147
|
function smallColumnTracks(tracks?: ColumnTrackSpec[]) {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
)
|
|
148
|
+
const sizes = columnTrackSizes(tracks)
|
|
149
|
+
const kept = tracks?.filter((_t, i) => sizes[i]! <= maxInlineSnapshotBytes)
|
|
134
150
|
return kept?.length ? { columnTracks: kept } : {}
|
|
135
151
|
}
|
|
136
152
|
|
|
@@ -178,11 +194,49 @@ export const preservedOnReset = new Set([
|
|
|
178
194
|
// until they touch that track, and then its value is whether the track is OFF.
|
|
179
195
|
// Reading the default through this is what lets a track ship hidden without
|
|
180
196
|
// writing an entry into every snapshot and shared URL.
|
|
197
|
+
// A track the file itself supplies says whether it starts hidden, since only
|
|
198
|
+
// the file knows how many of them there are: a Pfam seed carries a couple of
|
|
199
|
+
// #=GR lines, an Rfam family one per row.
|
|
181
200
|
function trackIsOff(
|
|
182
201
|
turnedOffTracks: { get: (id: string) => boolean | undefined },
|
|
183
202
|
id: string,
|
|
203
|
+
defaultOff?: boolean,
|
|
184
204
|
) {
|
|
185
|
-
return turnedOffTracks.get(id) ?? defaultOffTracks.has(id)
|
|
205
|
+
return turnedOffTracks.get(id) ?? (defaultOff || defaultOffTracks.has(id))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// one array for every "nothing under the pointer", since a fresh [] is a fresh
|
|
209
|
+
// value to every observer of it
|
|
210
|
+
const noDomains: Annotation[] = []
|
|
211
|
+
|
|
212
|
+
// seqPos -> column indexes, per row, hung off the parse the rows came from so
|
|
213
|
+
// they are collected with it. A computed would rebuild every row's index on
|
|
214
|
+
// each miss, and rebuild all of them again whenever it was read outside a
|
|
215
|
+
// reactive context.
|
|
216
|
+
const seqPosIndexCache = new WeakMap<object, Map<string, Int32Array>>()
|
|
217
|
+
|
|
218
|
+
// A segment asserts a 1:1 run, so its two sides have to be the same length.
|
|
219
|
+
// One that is not is malformed data, and the arithmetic below would answer
|
|
220
|
+
// anyway -- with a residue that is off by however much the sides disagree. Skip
|
|
221
|
+
// it, the same refusal an uncovered position gets.
|
|
222
|
+
function sameLength(segment: ResidueSegment) {
|
|
223
|
+
return (
|
|
224
|
+
segment.rowEnd - segment.rowStart ===
|
|
225
|
+
segment.structEnd - segment.structStart
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Does the content need a scrollbar? fit() divides the viewport by the row or
|
|
230
|
+
// column count and multiplies it back, so an exact fit lands a fraction of a
|
|
231
|
+
// pixel over -- enough for a `>` to answer yes and hand the reader a minimap or
|
|
232
|
+
// a scrollbar for half a pixel of nothing, which then shrinks the viewport and
|
|
233
|
+
// leaves a gap.
|
|
234
|
+
function overflows(content: number, viewport: number) {
|
|
235
|
+
return content - viewport > 0.5
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function inRanges(ranges: [number, number][] | undefined, position: number) {
|
|
239
|
+
return !!ranges?.some(([start, end]) => position >= start && position <= end)
|
|
186
240
|
}
|
|
187
241
|
|
|
188
242
|
/**
|
|
@@ -352,6 +406,19 @@ function stateModelFactory() {
|
|
|
352
406
|
[],
|
|
353
407
|
),
|
|
354
408
|
|
|
409
|
+
/**
|
|
410
|
+
* #property
|
|
411
|
+
* which residue of which structure each row's residues are, as data.
|
|
412
|
+
* The viewer cannot infer this -- matching a row to a structure by
|
|
413
|
+
* sequence equality fails for a tagged construct, a truncation or a
|
|
414
|
+
* subsequence row, and fails in the direction that looks like it
|
|
415
|
+
* worked -- so it arrives computed. See docs/layers.md
|
|
416
|
+
*/
|
|
417
|
+
residueMappings: stripDefault(
|
|
418
|
+
types.array(types.frozen<ResidueMapping>()),
|
|
419
|
+
[],
|
|
420
|
+
),
|
|
421
|
+
|
|
355
422
|
/**
|
|
356
423
|
* #property
|
|
357
424
|
* data from the loaded tree/msa/treeMetadata, generally loaded by
|
|
@@ -365,8 +432,13 @@ function stateModelFactory() {
|
|
|
365
432
|
|
|
366
433
|
/**
|
|
367
434
|
* #property
|
|
435
|
+
* the user's explicit hide choices per annotation accession, keyed by
|
|
436
|
+
* accession with the value meaning "off", the same shape as
|
|
437
|
+
* `turnedOffTracks`. An accession the user has never touched is absent
|
|
438
|
+
* and drawn, so a file of two hundred domain types adds nothing to the
|
|
439
|
+
* shared URL until someone filters one out
|
|
368
440
|
*/
|
|
369
|
-
|
|
441
|
+
turnedOffFeatures: stripDefault(types.map(types.boolean), {}),
|
|
370
442
|
/**
|
|
371
443
|
* #property
|
|
372
444
|
*/
|
|
@@ -398,9 +470,7 @@ function stateModelFactory() {
|
|
|
398
470
|
/**
|
|
399
471
|
* #volatile
|
|
400
472
|
*/
|
|
401
|
-
status: undefined as
|
|
402
|
-
| { msg: string; url?: string; onCancel?: () => void }
|
|
403
|
-
| undefined,
|
|
473
|
+
status: undefined as { msg: string; onCancel?: () => void } | undefined,
|
|
404
474
|
/**
|
|
405
475
|
* #volatile
|
|
406
476
|
* high resolution scale factor, helps make canvas look better on hi-dpi
|
|
@@ -472,6 +542,16 @@ function stateModelFactory() {
|
|
|
472
542
|
*/
|
|
473
543
|
highlightedColumns: undefined as number[] | undefined,
|
|
474
544
|
|
|
545
|
+
/**
|
|
546
|
+
* #volatile
|
|
547
|
+
* transient highlights keyed by who asked for them. One slot cannot hold
|
|
548
|
+
* two sources -- a structure viewer's hover and a genome view's hover
|
|
549
|
+
* both want to point at a column, and with one slot whoever clears last
|
|
550
|
+
* erases the other's. Keyed by owner, each source adds and removes only
|
|
551
|
+
* its own. Not persisted: a hover is not part of the document.
|
|
552
|
+
*/
|
|
553
|
+
transientHighlights: {} as Record<string, Highlight[]>,
|
|
554
|
+
|
|
475
555
|
/**
|
|
476
556
|
* #volatile
|
|
477
557
|
*/
|
|
@@ -482,6 +562,15 @@ function stateModelFactory() {
|
|
|
482
562
|
*/
|
|
483
563
|
conservationTrackHeight: 40,
|
|
484
564
|
|
|
565
|
+
/**
|
|
566
|
+
* #volatile
|
|
567
|
+
* heights of individual `columnTracks`, by track id. A data track is
|
|
568
|
+
* resized on its own: the shared per-kind heights below belong to the
|
|
569
|
+
* tracks the viewer computes, and dragging a data track's handle used to
|
|
570
|
+
* resize those instead.
|
|
571
|
+
*/
|
|
572
|
+
columnTrackHeights: {} as Record<string, number>,
|
|
573
|
+
|
|
485
574
|
/**
|
|
486
575
|
* #volatile
|
|
487
576
|
* taller than the conservation track by default: the logo spends its
|
|
@@ -490,6 +579,11 @@ function stateModelFactory() {
|
|
|
490
579
|
*/
|
|
491
580
|
sequenceLogoTrackHeight: 80,
|
|
492
581
|
|
|
582
|
+
/**
|
|
583
|
+
* #volatile
|
|
584
|
+
*/
|
|
585
|
+
arcTrackHeight: 50,
|
|
586
|
+
|
|
493
587
|
/**
|
|
494
588
|
* #volatile
|
|
495
589
|
*/
|
|
@@ -501,6 +595,32 @@ function stateModelFactory() {
|
|
|
501
595
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
502
596
|
error: undefined as unknown,
|
|
503
597
|
|
|
598
|
+
/**
|
|
599
|
+
* #volatile
|
|
600
|
+
* load problems the view carried on through: an optional layer that did
|
|
601
|
+
* not arrive, an overlay that did not parse. `error` is the other kind --
|
|
602
|
+
* it replaces the view, which is right for the alignment and wrong for a
|
|
603
|
+
* decorative file
|
|
604
|
+
*/
|
|
605
|
+
warnings: [] as string[],
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* #volatile
|
|
609
|
+
* bumped by reset(). The React error boundary above the view keeps its
|
|
610
|
+
* caught error until it is remounted, so "Return to import form" did
|
|
611
|
+
* nothing after a render error until this became its key
|
|
612
|
+
*/
|
|
613
|
+
resetCount: 0,
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* #volatile
|
|
617
|
+
* set by a host that restores the loaded documents by its own means --
|
|
618
|
+
* a jbrowse session that holds them, a page that refetches them on load.
|
|
619
|
+
* `unshareableData` then reports nothing, since what it warns about is a
|
|
620
|
+
* link that opens empty, and under such a host the link does not
|
|
621
|
+
*/
|
|
622
|
+
hostCarriesData: false,
|
|
623
|
+
|
|
504
624
|
/**
|
|
505
625
|
* #volatile
|
|
506
626
|
* overlay annotations drawn on the alignment, whatever their source.
|
|
@@ -574,13 +694,38 @@ function stateModelFactory() {
|
|
|
574
694
|
self.error = error
|
|
575
695
|
},
|
|
576
696
|
|
|
697
|
+
/**
|
|
698
|
+
* #action
|
|
699
|
+
* report something the view survived: a layer that failed to load, a
|
|
700
|
+
* file that failed to parse
|
|
701
|
+
*/
|
|
702
|
+
addWarning(warning: string) {
|
|
703
|
+
self.warnings = [...self.warnings, warning]
|
|
704
|
+
},
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* #action
|
|
708
|
+
*/
|
|
709
|
+
clearWarnings() {
|
|
710
|
+
self.warnings = []
|
|
711
|
+
},
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* #action
|
|
715
|
+
* declare that this host restores the loaded documents itself, which
|
|
716
|
+
* takes down the "Not in the link" warning. See `hostCarriesData`
|
|
717
|
+
*/
|
|
718
|
+
setHostCarriesData(arg: boolean) {
|
|
719
|
+
self.hostCarriesData = arg
|
|
720
|
+
},
|
|
721
|
+
|
|
577
722
|
/**
|
|
578
723
|
* #action
|
|
579
724
|
* set mouse position (row, column) in the MSA
|
|
580
725
|
*
|
|
581
|
-
*
|
|
582
|
-
*
|
|
583
|
-
*
|
|
726
|
+
* PUBLIC API: a host drives this (and reads the `mouseCol` volatile) to
|
|
727
|
+
* sync the alignment's hover with a view of its own -- a genome view, a
|
|
728
|
+
* 3D structure. Keep the name and signature stable.
|
|
584
729
|
*/
|
|
585
730
|
setMousePos(col?: number, row?: number) {
|
|
586
731
|
self.mouseCol = col
|
|
@@ -591,9 +736,9 @@ function stateModelFactory() {
|
|
|
591
736
|
* #action
|
|
592
737
|
* set highlighted columns
|
|
593
738
|
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
739
|
+
* PUBLIC API: jbrowse-plugin-msaview calls this from its
|
|
740
|
+
* afterCreateAutoruns to highlight alignment columns, and MSAViewer
|
|
741
|
+
* passes its `highlightColumns` prop through it. Not dead code.
|
|
597
742
|
*/
|
|
598
743
|
setHighlightedColumns(columns?: number[]) {
|
|
599
744
|
self.highlightedColumns = columns
|
|
@@ -604,6 +749,29 @@ function stateModelFactory() {
|
|
|
604
749
|
setHighlights(highlights: Highlight[]) {
|
|
605
750
|
self.highlights.replace(highlights)
|
|
606
751
|
},
|
|
752
|
+
/**
|
|
753
|
+
* #action
|
|
754
|
+
* show `highlights` on behalf of `owner`, replacing whatever that owner
|
|
755
|
+
* showed before and leaving every other owner's alone. The object is
|
|
756
|
+
* replaced rather than mutated so one assignment is the observable
|
|
757
|
+
* change.
|
|
758
|
+
*/
|
|
759
|
+
applyHighlight(owner: string, highlights: Highlight[]) {
|
|
760
|
+
self.transientHighlights = {
|
|
761
|
+
...self.transientHighlights,
|
|
762
|
+
[owner]: highlights,
|
|
763
|
+
}
|
|
764
|
+
},
|
|
765
|
+
/**
|
|
766
|
+
* #action
|
|
767
|
+
* drop what `owner` was showing, leaving every other owner's in place
|
|
768
|
+
*/
|
|
769
|
+
clearHighlight(owner: string) {
|
|
770
|
+
if (owner in self.transientHighlights) {
|
|
771
|
+
const { [owner]: _dropped, ...rest } = self.transientHighlights
|
|
772
|
+
self.transientHighlights = rest
|
|
773
|
+
}
|
|
774
|
+
},
|
|
607
775
|
/**
|
|
608
776
|
* #action
|
|
609
777
|
* toggle the annotation overlay on the alignment
|
|
@@ -651,10 +819,23 @@ function stateModelFactory() {
|
|
|
651
819
|
|
|
652
820
|
/**
|
|
653
821
|
* #action
|
|
654
|
-
*
|
|
822
|
+
* switch to another alignment of a multi-alignment file (Stockholm). The
|
|
823
|
+
* new alignment has its own rows and its own tree, so everything naming
|
|
824
|
+
* the old one's -- the collapsed node ids, the subtree in focus, the
|
|
825
|
+
* reference row, the scroll position -- goes with it
|
|
655
826
|
*/
|
|
656
827
|
setCurrentAlignment(n: number) {
|
|
657
|
-
self.currentAlignment
|
|
828
|
+
if (n === self.currentAlignment) {
|
|
829
|
+
return
|
|
830
|
+
}
|
|
831
|
+
transaction(() => {
|
|
832
|
+
self.currentAlignment = n
|
|
833
|
+
self.collapsed.clear()
|
|
834
|
+
this.setShowOnly(undefined)
|
|
835
|
+
this.drawRelativeTo(undefined)
|
|
836
|
+
self.scrollX = 0
|
|
837
|
+
self.scrollY = 0
|
|
838
|
+
})
|
|
658
839
|
},
|
|
659
840
|
|
|
660
841
|
/**
|
|
@@ -740,6 +921,18 @@ function stateModelFactory() {
|
|
|
740
921
|
setTreeMetadata(result: string) {
|
|
741
922
|
self.data.setTreeMetadata(result)
|
|
742
923
|
},
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* #action
|
|
927
|
+
* keep the GFF text the way the alignment and the tree are kept, rather
|
|
928
|
+
* than only its parsed annotations. The annotations are volatile, so a
|
|
929
|
+
* file opened from disk used to leave no trace in the snapshot at all --
|
|
930
|
+
* not the text, and not the filehandle, which is cleared once a blob is
|
|
931
|
+
* read. An autorun parses this back into annotations.
|
|
932
|
+
*/
|
|
933
|
+
setGFF(result: string) {
|
|
934
|
+
self.data.setGFF(result)
|
|
935
|
+
},
|
|
743
936
|
}))
|
|
744
937
|
|
|
745
938
|
.views(self => ({
|
|
@@ -765,6 +958,22 @@ function stateModelFactory() {
|
|
|
765
958
|
get actuallyShowDomains() {
|
|
766
959
|
return self.showDomains && self.annotations.length > 0
|
|
767
960
|
},
|
|
961
|
+
/**
|
|
962
|
+
* #getter
|
|
963
|
+
* whether this host brings the loaded documents back by means the
|
|
964
|
+
* snapshot cannot see, which is what decides whether `unshareableData`
|
|
965
|
+
* has anything to warn about.
|
|
966
|
+
*
|
|
967
|
+
* A simple host flips the `hostCarriesData` volatile. A host whose
|
|
968
|
+
* answer depends on how the view was opened overrides this getter in a
|
|
969
|
+
* `.views` block of its own composed model -- jbrowse-plugin-msaview's
|
|
970
|
+
* indexed-location views refetch from a URL the session holds, while its
|
|
971
|
+
* data-store views really are absent from a link someone pastes
|
|
972
|
+
* elsewhere. `unshareableData` reads it off `self`, so an override wins.
|
|
973
|
+
*/
|
|
974
|
+
get hostRestoresData() {
|
|
975
|
+
return self.hostCarriesData
|
|
976
|
+
},
|
|
768
977
|
get viewInitialized() {
|
|
769
978
|
return self.volatileWidth !== undefined
|
|
770
979
|
},
|
|
@@ -779,13 +988,6 @@ function stateModelFactory() {
|
|
|
779
988
|
},
|
|
780
989
|
}))
|
|
781
990
|
.views(self => ({
|
|
782
|
-
/**
|
|
783
|
-
* #method
|
|
784
|
-
* unused here, but can be used by derived classes to add extra items
|
|
785
|
-
*/
|
|
786
|
-
extraViewMenuItems() {
|
|
787
|
-
return []
|
|
788
|
-
},
|
|
789
991
|
/**
|
|
790
992
|
* #getter
|
|
791
993
|
*/
|
|
@@ -820,6 +1022,53 @@ function stateModelFactory() {
|
|
|
820
1022
|
get noDomains() {
|
|
821
1023
|
return self.annotations.length === 0
|
|
822
1024
|
},
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* #getter
|
|
1028
|
+
* the loaded documents this view's own snapshot cannot carry, largest
|
|
1029
|
+
* first. A file opened from disk or pasted in becomes inline text, and
|
|
1030
|
+
* DataModel drops an inline document past `maxInlineSnapshotBytes`
|
|
1031
|
+
* rather than put megabytes of sequence into a session or a URL.
|
|
1032
|
+
*
|
|
1033
|
+
* Dropping it is right. Dropping it silently is what makes a copied link
|
|
1034
|
+
* open an empty viewer, so the header says so and the standalone app
|
|
1035
|
+
* stops rewriting the address bar while this is non-empty. A document
|
|
1036
|
+
* fetched from a URL never appears here whatever its size: the snapshot
|
|
1037
|
+
* keeps the filehandle and refetches through it.
|
|
1038
|
+
*
|
|
1039
|
+
* Nothing is unshareable when the host restores the data by its own
|
|
1040
|
+
* means (see `hostRestoresData`) -- inside a session that reloads these
|
|
1041
|
+
* documents from somewhere the snapshot does not show, the warning is
|
|
1042
|
+
* simply wrong.
|
|
1043
|
+
*/
|
|
1044
|
+
get unshareableData(): UnshareableData[] {
|
|
1045
|
+
if (self.hostRestoresData) {
|
|
1046
|
+
return []
|
|
1047
|
+
}
|
|
1048
|
+
const { data } = self
|
|
1049
|
+
// a data track past the limit leaves the snapshot the same way an
|
|
1050
|
+
// inline document does, and left unreported the same way too
|
|
1051
|
+
const trackBytes = columnTrackSizes(self.columnTracks)
|
|
1052
|
+
.filter(bytes => bytes > maxInlineSnapshotBytes)
|
|
1053
|
+
.reduce((a, b) => a + b, 0)
|
|
1054
|
+
return (
|
|
1055
|
+
[
|
|
1056
|
+
['alignment', data.msa, self.msaFilehandle],
|
|
1057
|
+
['tree', data.tree, self.treeFilehandle],
|
|
1058
|
+
['annotations', data.gff, self.gffFilehandle],
|
|
1059
|
+
['row metadata', data.treeMetadata, self.treeMetadataFilehandle],
|
|
1060
|
+
] as const
|
|
1061
|
+
)
|
|
1062
|
+
.flatMap<UnshareableData>(([what, text, filehandle]) =>
|
|
1063
|
+
!filehandle && text && text.length > maxInlineSnapshotBytes
|
|
1064
|
+
? [{ what, bytes: text.length }]
|
|
1065
|
+
: [],
|
|
1066
|
+
)
|
|
1067
|
+
.concat(
|
|
1068
|
+
trackBytes ? [{ what: 'data tracks', bytes: trackBytes }] : [],
|
|
1069
|
+
)
|
|
1070
|
+
.sort((a, b) => b.bytes - a.bytes)
|
|
1071
|
+
},
|
|
823
1072
|
menuItems() {
|
|
824
1073
|
return []
|
|
825
1074
|
},
|
|
@@ -1047,24 +1296,51 @@ function stateModelFactory() {
|
|
|
1047
1296
|
},
|
|
1048
1297
|
|
|
1049
1298
|
/**
|
|
1050
|
-
* #
|
|
1051
|
-
*
|
|
1052
|
-
*
|
|
1053
|
-
*
|
|
1299
|
+
* #method
|
|
1300
|
+
* index of the global column holding each ungapped sequence position of a
|
|
1301
|
+
* row, so seqPos -> column is a lookup rather than a scan. The domain
|
|
1302
|
+
* overlay resolves thousands of these per redraw.
|
|
1303
|
+
*
|
|
1304
|
+
* Built per row, on the row asked for: the first lookup used to index
|
|
1305
|
+
* every row in the alignment. The cache is keyed on the parse the rows
|
|
1306
|
+
* came from, so a new alignment brings a new one.
|
|
1054
1307
|
*/
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
)
|
|
1308
|
+
seqPosIndex(rowName: string): Int32Array | undefined {
|
|
1309
|
+
const MSA = this.MSA
|
|
1310
|
+
if (!MSA) {
|
|
1311
|
+
return undefined
|
|
1312
|
+
}
|
|
1313
|
+
let cache = seqPosIndexCache.get(MSA)
|
|
1314
|
+
if (!cache) {
|
|
1315
|
+
cache = new Map()
|
|
1316
|
+
seqPosIndexCache.set(MSA, cache)
|
|
1317
|
+
}
|
|
1318
|
+
let index = cache.get(rowName)
|
|
1319
|
+
if (!index) {
|
|
1320
|
+
const seq = MSA.getRow(rowName)
|
|
1321
|
+
if (!seq) {
|
|
1322
|
+
return undefined
|
|
1323
|
+
}
|
|
1324
|
+
index = buildSeqPosIndex(seq)
|
|
1325
|
+
cache.set(rowName, index)
|
|
1326
|
+
}
|
|
1327
|
+
return index
|
|
1061
1328
|
},
|
|
1062
1329
|
|
|
1063
1330
|
/**
|
|
1064
1331
|
* #getter
|
|
1332
|
+
* every sequence the alignment holds, keyed by row name, whatever the
|
|
1333
|
+
* tree currently shows. `rows` is the rows on screen; this is the rows
|
|
1334
|
+
* that exist, and every lookup about a named row goes through it --
|
|
1335
|
+
* collapsing a clade hides rows, it does not delete their sequence
|
|
1065
1336
|
*/
|
|
1066
1337
|
get rowMap() {
|
|
1067
|
-
|
|
1338
|
+
const MSA = this.MSA
|
|
1339
|
+
return new Map(
|
|
1340
|
+
MSA?.getNames()
|
|
1341
|
+
.map(name => [name, MSA.getRow(name)] as const)
|
|
1342
|
+
.filter(([, seq]) => !!seq),
|
|
1343
|
+
)
|
|
1068
1344
|
},
|
|
1069
1345
|
/**
|
|
1070
1346
|
* #getter
|
|
@@ -1201,10 +1477,13 @@ function stateModelFactory() {
|
|
|
1201
1477
|
get hierarchy(): HierarchyNode<NodeWithIdsAndLength> {
|
|
1202
1478
|
const r = this.root
|
|
1203
1479
|
clusterLayout(r, this.totalHeight, self.treeWidth)
|
|
1204
|
-
|
|
1205
|
-
const max = maxLength(r)
|
|
1480
|
+
const max = this.rootToTipLength
|
|
1206
1481
|
const k = max ? self.treeWidth / max : 0
|
|
1207
|
-
|
|
1482
|
+
// the displayed root starts at x=0 whatever branch length it carries,
|
|
1483
|
+
// so its own length is subtracted here rather than zeroed on the parsed
|
|
1484
|
+
// node -- `root` hands out the cached parse, and writing to it made
|
|
1485
|
+
// showOnly shorten that branch for good
|
|
1486
|
+
setBrLength(r, -Math.max(r.data.length || 0, 0), k)
|
|
1208
1487
|
// for each collapsed clade, record the pixel x-position of its farthest
|
|
1209
1488
|
// tip so the renderer can draw a triangle spanning the branch-length
|
|
1210
1489
|
// extent of the hidden subtree
|
|
@@ -1233,10 +1512,34 @@ function stateModelFactory() {
|
|
|
1233
1512
|
|
|
1234
1513
|
/**
|
|
1235
1514
|
* #getter
|
|
1236
|
-
*
|
|
1515
|
+
* branch-length extent of the displayed tree, root to farthest tip, in
|
|
1516
|
+
* the tree's own units
|
|
1517
|
+
*/
|
|
1518
|
+
get rootToTipLength() {
|
|
1519
|
+
const r = this.root
|
|
1520
|
+
return maxLength(r) - Math.max(r.data.length || 0, 0)
|
|
1521
|
+
},
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* #getter
|
|
1525
|
+
* x-position of the farthest tip in a phylogram, px. The layout scales
|
|
1526
|
+
* the longest root-to-tip path onto treeWidth, so that is where it lands
|
|
1527
|
+
* -- and 0 for a tree carrying no lengths at all, which draws as a
|
|
1528
|
+
* cladogram instead
|
|
1237
1529
|
*/
|
|
1238
1530
|
get maxBranchLength() {
|
|
1239
|
-
return
|
|
1531
|
+
return this.rootToTipLength ? self.treeWidth : 0
|
|
1532
|
+
},
|
|
1533
|
+
|
|
1534
|
+
/**
|
|
1535
|
+
* #getter
|
|
1536
|
+
* pixels per unit of branch length in the current phylogram layout, and 0
|
|
1537
|
+
* in cladogram mode, where the x-positions carry no length at all. The
|
|
1538
|
+
* scale bar over the tree is drawn from it.
|
|
1539
|
+
*/
|
|
1540
|
+
get pxPerBranchLength() {
|
|
1541
|
+
const max = maxLength(this.root)
|
|
1542
|
+
return this.showBranchLenEffective && max ? self.treeWidth / max : 0
|
|
1240
1543
|
},
|
|
1241
1544
|
|
|
1242
1545
|
/**
|
|
@@ -1251,7 +1554,7 @@ function stateModelFactory() {
|
|
|
1251
1554
|
* #getter
|
|
1252
1555
|
*/
|
|
1253
1556
|
get allBranchesLength0() {
|
|
1254
|
-
return
|
|
1557
|
+
return this.rootToTipLength === 0
|
|
1255
1558
|
},
|
|
1256
1559
|
|
|
1257
1560
|
/**
|
|
@@ -1275,673 +1578,1018 @@ function stateModelFactory() {
|
|
|
1275
1578
|
/**
|
|
1276
1579
|
* #getter
|
|
1277
1580
|
*/
|
|
1278
|
-
get
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
},
|
|
1286
|
-
/**
|
|
1287
|
-
* #getter
|
|
1288
|
-
*/
|
|
1289
|
-
get blocksX() {
|
|
1290
|
-
return calculateBlocks({
|
|
1291
|
-
viewportSize: self.msaAreaWidth,
|
|
1292
|
-
viewportPos: -self.scrollX,
|
|
1293
|
-
blockSize: self.blockSize,
|
|
1294
|
-
mapSize: self.totalWidth,
|
|
1295
|
-
})
|
|
1296
|
-
},
|
|
1297
|
-
/**
|
|
1298
|
-
* #getter
|
|
1299
|
-
*/
|
|
1300
|
-
get blocksY() {
|
|
1301
|
-
return calculateBlocks({
|
|
1302
|
-
viewportSize: this.visibleMsaHeight,
|
|
1303
|
-
viewportPos: -self.scrollY,
|
|
1304
|
-
blockSize: self.blockSize,
|
|
1305
|
-
mapSize: self.totalHeight,
|
|
1306
|
-
})
|
|
1581
|
+
get showMsaLetters() {
|
|
1582
|
+
return (
|
|
1583
|
+
self.drawMsaLetters &&
|
|
1584
|
+
self.rowHeight >= minLetterRowHeight &&
|
|
1585
|
+
self.colWidth >= minLetterColWidth &&
|
|
1586
|
+
self.colWidth > self.rowHeight / 2
|
|
1587
|
+
)
|
|
1307
1588
|
},
|
|
1308
1589
|
/**
|
|
1309
1590
|
* #getter
|
|
1310
|
-
* height of the alignment viewport, px. The same subtraction as
|
|
1311
|
-
* msaAreaHeight, which is defined later in the views chain
|
|
1312
1591
|
*/
|
|
1313
|
-
get
|
|
1314
|
-
return
|
|
1315
|
-
self.height -
|
|
1316
|
-
self.headerHeight -
|
|
1317
|
-
(self.msaAreaWidth < self.totalWidth ? self.minimapHeight : 0)
|
|
1318
|
-
)
|
|
1592
|
+
get showTreeText() {
|
|
1593
|
+
return self.drawLabels && self.rowHeight >= minLetterRowHeight
|
|
1319
1594
|
},
|
|
1320
1595
|
}))
|
|
1321
1596
|
.views(self => ({
|
|
1322
1597
|
/**
|
|
1323
1598
|
* #getter
|
|
1324
1599
|
*/
|
|
1325
|
-
get
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1600
|
+
get labelWidthMap() {
|
|
1601
|
+
const { showTreeText, leaves, treeMetadata } = self
|
|
1602
|
+
// gated on the same condition the renderer draws labels under, so the
|
|
1603
|
+
// gutter labelsWidth reserves and the labels actually drawn cannot
|
|
1604
|
+
// disagree -- and so turning labels off hands their space to the tree.
|
|
1605
|
+
// Measured at a fixed reference size and scaled by labelWidthScale:
|
|
1606
|
+
// re-measuring every leaf on every vertical-zoom frame cost ~200ms on a
|
|
1607
|
+
// 50k-leaf tree
|
|
1608
|
+
return showTreeText
|
|
1609
|
+
? new Map(
|
|
1610
|
+
leaves.map(node => {
|
|
1611
|
+
const { name } = node.data
|
|
1612
|
+
// `||`, matching renderTreeLabels: an empty genome falls back
|
|
1613
|
+
// to the row name, and measuring '' would size the gutter (and
|
|
1614
|
+
// the label's click target) to nothing
|
|
1615
|
+
const displayName = treeMetadata[name]?.genome || name
|
|
1616
|
+
return [
|
|
1617
|
+
name,
|
|
1618
|
+
measureTextCanvas(displayName, labelReferenceFontSize),
|
|
1619
|
+
] as const
|
|
1620
|
+
}),
|
|
1621
|
+
)
|
|
1622
|
+
: new Map<string, number>()
|
|
1329
1623
|
},
|
|
1330
1624
|
|
|
1331
1625
|
/**
|
|
1332
1626
|
* #getter
|
|
1627
|
+
* factor turning a labelWidthMap entry into its width at the current
|
|
1628
|
+
* font size
|
|
1333
1629
|
*/
|
|
1334
|
-
get
|
|
1335
|
-
return self.
|
|
1630
|
+
get labelWidthScale() {
|
|
1631
|
+
return self.fontSize / labelReferenceFontSize
|
|
1632
|
+
},
|
|
1633
|
+
|
|
1634
|
+
get labelsWidth() {
|
|
1635
|
+
// a loop, not Math.max(...widths.values()): spreading a map of every
|
|
1636
|
+
// leaf passes one argument per row, and the argument limit is somewhere
|
|
1637
|
+
// around 125k -- so the bundled 230k-tip COVID tree threw
|
|
1638
|
+
// "RangeError: Maximum call stack size exceeded" out of a getter the
|
|
1639
|
+
// treeWidth autorun reads on load
|
|
1640
|
+
let max = 0
|
|
1641
|
+
for (const width of this.labelWidthMap.values()) {
|
|
1642
|
+
if (width > max) {
|
|
1643
|
+
max = width
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
return max * this.labelWidthScale
|
|
1336
1647
|
},
|
|
1648
|
+
|
|
1337
1649
|
/**
|
|
1338
1650
|
* #getter
|
|
1339
1651
|
*/
|
|
1340
|
-
get
|
|
1341
|
-
return
|
|
1652
|
+
get secondaryStructureConsensus() {
|
|
1653
|
+
return self.MSA?.secondaryStructureConsensus
|
|
1342
1654
|
},
|
|
1655
|
+
|
|
1343
1656
|
/**
|
|
1344
1657
|
* #getter
|
|
1345
|
-
* most-negative allowed scrollY, keeping the last row in view rather than
|
|
1346
|
-
* letting the whole alignment scroll off the top.
|
|
1347
1658
|
*/
|
|
1348
|
-
get
|
|
1349
|
-
return
|
|
1659
|
+
get seqConsensus() {
|
|
1660
|
+
return self.MSA?.seqConsensus
|
|
1350
1661
|
},
|
|
1662
|
+
|
|
1351
1663
|
/**
|
|
1352
1664
|
* #getter
|
|
1665
|
+
* the base pairs of the consensus secondary structure, as arcs. The WUSS
|
|
1666
|
+
* string is collapsed through the hidden columns before it is parsed, so
|
|
1667
|
+
* the pairs land in the same visible column space the text track does
|
|
1353
1668
|
*/
|
|
1354
|
-
get
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1669
|
+
get secondaryStructureArcs(): Arc[] | undefined {
|
|
1670
|
+
const { blanks, hideGapsEffective } = self
|
|
1671
|
+
const ss = this.secondaryStructureConsensus
|
|
1672
|
+
if (!ss) {
|
|
1673
|
+
return undefined
|
|
1674
|
+
}
|
|
1675
|
+
return parseWuss(hideGapsEffective ? skipBlanks(blanks, ss) : ss).map(
|
|
1676
|
+
({ start, end, pseudoknot }) => ({
|
|
1677
|
+
start,
|
|
1678
|
+
end,
|
|
1679
|
+
color: pseudoknot ? PSEUDOKNOT_ARC : HELIX_ARC,
|
|
1680
|
+
}),
|
|
1360
1681
|
)
|
|
1361
1682
|
},
|
|
1683
|
+
|
|
1362
1684
|
/**
|
|
1363
1685
|
* #getter
|
|
1364
1686
|
*/
|
|
1365
|
-
get
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1687
|
+
get adapterTrackModels(): BasicTrack[] {
|
|
1688
|
+
const { MSA, hideGapsEffective, blanks } = self
|
|
1689
|
+
const tracks = (MSA?.tracks ?? []).filter(t => !!t.data)
|
|
1690
|
+
if (tracks.length === 0) {
|
|
1691
|
+
// reading rowHeight up front made every zoom frame rebuild the track
|
|
1692
|
+
// list, and the canvases redraw on the track object they are handed
|
|
1693
|
+
// changing
|
|
1694
|
+
return []
|
|
1695
|
+
}
|
|
1696
|
+
const { rowHeight } = self
|
|
1697
|
+
return tracks.map(t => ({
|
|
1698
|
+
model: {
|
|
1699
|
+
...t,
|
|
1700
|
+
kind: 'text' as const,
|
|
1701
|
+
data: hideGapsEffective ? skipBlanks(blanks, t.data!) : t.data,
|
|
1702
|
+
height: rowHeight,
|
|
1703
|
+
},
|
|
1704
|
+
ReactComponent: TrackBlocks,
|
|
1705
|
+
}))
|
|
1375
1706
|
},
|
|
1376
1707
|
|
|
1377
1708
|
/**
|
|
1378
|
-
* #
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
self.scrollZoom = arg
|
|
1382
|
-
},
|
|
1383
|
-
|
|
1384
|
-
/**
|
|
1385
|
-
* #action
|
|
1386
|
-
* set hovered tree node and its descendants
|
|
1709
|
+
* #getter
|
|
1710
|
+
* a data track's values or string, projected from its row's residues
|
|
1711
|
+
* onto alignment columns when it names a row
|
|
1387
1712
|
*/
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1713
|
+
get columnTrackContent() {
|
|
1714
|
+
const { MSA, blanks, hideGapsEffective } = self
|
|
1715
|
+
const width = MSA?.getWidth() ?? 0
|
|
1716
|
+
const project = <T>(track: ColumnTrackSpec, items: T[], fill: T) => {
|
|
1717
|
+
if (!track.row) {
|
|
1718
|
+
return items
|
|
1719
|
+
}
|
|
1720
|
+
const out = Array.from({ length: width }, () => fill)
|
|
1721
|
+
const index = self.seqPosIndex(track.row)
|
|
1722
|
+
items.forEach((item, seqPos) => {
|
|
1723
|
+
const col = index?.[seqPos]
|
|
1724
|
+
if (col !== undefined) {
|
|
1725
|
+
out[col] = item
|
|
1726
|
+
}
|
|
1727
|
+
})
|
|
1728
|
+
return out
|
|
1395
1729
|
}
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1730
|
+
const skip = <T>(items: T[]) =>
|
|
1731
|
+
hideGapsEffective ? dropBlanks(blanks, items) : items
|
|
1732
|
+
// an arc names two positions rather than one per column, so it takes
|
|
1733
|
+
// the same two steps the arrays take -- a row's residues onto columns,
|
|
1734
|
+
// then columns onto the visible ones -- as a lookup. visibleColsBefore,
|
|
1735
|
+
// not globalColToVisibleCol: an endpoint in a hidden column collapses
|
|
1736
|
+
// to where that column went instead of taking the whole arc with it
|
|
1737
|
+
const resolve = (track: ColumnTrackSpec, pos: number) => {
|
|
1738
|
+
const col = track.row
|
|
1739
|
+
? self.seqPosIndex(track.row)?.[pos - 1]
|
|
1740
|
+
: pos - 1
|
|
1741
|
+
if (col === undefined || col < 0 || col >= width) {
|
|
1742
|
+
return undefined
|
|
1743
|
+
}
|
|
1744
|
+
return hideGapsEffective ? visibleColsBefore(blanks, col) : col
|
|
1399
1745
|
}
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1746
|
+
return new Map<
|
|
1747
|
+
string,
|
|
1748
|
+
{ values?: number[]; data?: string; arcs?: Arc[] }
|
|
1749
|
+
>(
|
|
1750
|
+
self.columnTracks.map(track => {
|
|
1751
|
+
if (track.kind === 'arc') {
|
|
1752
|
+
const arcs = (track.arcs ?? [])
|
|
1753
|
+
.map(arc => {
|
|
1754
|
+
const start = resolve(track, Math.min(arc.start, arc.end))
|
|
1755
|
+
const end = resolve(track, Math.max(arc.start, arc.end))
|
|
1756
|
+
return start !== undefined && end !== undefined && start < end
|
|
1757
|
+
? { start, end, color: arc.color }
|
|
1758
|
+
: undefined
|
|
1759
|
+
})
|
|
1760
|
+
.filter(notEmpty)
|
|
1761
|
+
return [track.id, { arcs }] as const
|
|
1405
1762
|
}
|
|
1406
|
-
|
|
1763
|
+
if (track.kind === 'bar') {
|
|
1764
|
+
const max = track.max ?? 1
|
|
1765
|
+
const values = skip(project(track, track.values ?? [], 0)).map(
|
|
1766
|
+
v => Math.min(1, Math.max(0, v / max)),
|
|
1767
|
+
)
|
|
1768
|
+
return [track.id, { values }] as const
|
|
1769
|
+
}
|
|
1770
|
+
const data = skip(project(track, (track.data ?? '').split(''), ' '))
|
|
1771
|
+
return [track.id, { data: data.join('') }] as const
|
|
1772
|
+
}),
|
|
1773
|
+
)
|
|
1407
1774
|
},
|
|
1408
|
-
|
|
1409
1775
|
/**
|
|
1410
|
-
* #
|
|
1411
|
-
* Calculate a neighbor joining tree from the current MSA using BLOSUM62 distances
|
|
1776
|
+
* #getter
|
|
1412
1777
|
*/
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
const
|
|
1418
|
-
|
|
1778
|
+
get columnTrackModels(): BasicTrack[] {
|
|
1779
|
+
// read per kind, not up front: a text track is the only kind sized by
|
|
1780
|
+
// the row height, and reading it here rebuilt every data track on every
|
|
1781
|
+
// vertical zoom step
|
|
1782
|
+
const defaultHeight = (kind: ColumnTrackSpec['kind']) =>
|
|
1783
|
+
kind === 'bar'
|
|
1784
|
+
? self.conservationTrackHeight
|
|
1785
|
+
: kind === 'arc'
|
|
1786
|
+
? self.arcTrackHeight
|
|
1787
|
+
: self.rowHeight
|
|
1788
|
+
return self.columnTracks.map(track => ({
|
|
1789
|
+
model: {
|
|
1790
|
+
id: track.id,
|
|
1791
|
+
name: track.name,
|
|
1792
|
+
kind: track.kind,
|
|
1793
|
+
height:
|
|
1794
|
+
self.columnTrackHeights[track.id] ??
|
|
1795
|
+
track.height ??
|
|
1796
|
+
defaultHeight(track.kind),
|
|
1797
|
+
// the spec has one `color`; bar and arc are separate track models
|
|
1798
|
+
// that read it under their own name
|
|
1799
|
+
barColor: track.color,
|
|
1800
|
+
arcColor: track.color,
|
|
1801
|
+
customColorScheme: track.colors,
|
|
1802
|
+
data: this.columnTrackContent.get(track.id)?.data,
|
|
1803
|
+
arcs: this.columnTrackContent.get(track.id)?.arcs,
|
|
1804
|
+
},
|
|
1805
|
+
ReactComponent: TrackBlocks,
|
|
1806
|
+
}))
|
|
1807
|
+
},
|
|
1808
|
+
/**
|
|
1809
|
+
* #getter
|
|
1810
|
+
*/
|
|
1811
|
+
/**
|
|
1812
|
+
* #getter
|
|
1813
|
+
* the consensus secondary structure as a track, when there is one. Its
|
|
1814
|
+
* own getter so the object keeps its identity across a zoom: the canvas
|
|
1815
|
+
* redraws on the track it is handed changing, and rebuilding these
|
|
1816
|
+
* alongside everything else made every zoom frame redraw every track
|
|
1817
|
+
*/
|
|
1818
|
+
get basePairTrackModels(): BasicTrack[] {
|
|
1819
|
+
const arcs = this.secondaryStructureArcs
|
|
1820
|
+
return arcs?.length
|
|
1821
|
+
? [
|
|
1822
|
+
{
|
|
1823
|
+
model: {
|
|
1824
|
+
id: 'base-pairs',
|
|
1825
|
+
name: 'Base pairs',
|
|
1826
|
+
kind: 'arc' as const,
|
|
1827
|
+
height: self.arcTrackHeight,
|
|
1828
|
+
arcs,
|
|
1829
|
+
},
|
|
1830
|
+
ReactComponent: TrackBlocks,
|
|
1831
|
+
},
|
|
1832
|
+
]
|
|
1833
|
+
: []
|
|
1419
1834
|
},
|
|
1420
1835
|
|
|
1421
1836
|
/**
|
|
1422
|
-
* #
|
|
1423
|
-
*
|
|
1837
|
+
* #getter
|
|
1838
|
+
* the tracks computed from the alignment itself, which depend on their
|
|
1839
|
+
* own heights and on the alphabet -- and on nothing zoom changes
|
|
1424
1840
|
*/
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1841
|
+
get computedTrackModels(): BasicTrack[] {
|
|
1842
|
+
return [
|
|
1843
|
+
{
|
|
1844
|
+
id: 'conservation',
|
|
1845
|
+
name: 'Conservation',
|
|
1846
|
+
kind: 'bar' as const,
|
|
1847
|
+
height: self.conservationTrackHeight,
|
|
1848
|
+
barColor: 'gray',
|
|
1849
|
+
},
|
|
1850
|
+
...(self.sequenceType === 'amino'
|
|
1851
|
+
? [
|
|
1852
|
+
{
|
|
1853
|
+
id: 'property-conservation',
|
|
1854
|
+
name: 'Property conservation',
|
|
1855
|
+
kind: 'bar' as const,
|
|
1856
|
+
height: self.conservationTrackHeight,
|
|
1857
|
+
barColor: '#6a51a3',
|
|
1858
|
+
},
|
|
1859
|
+
]
|
|
1860
|
+
: []),
|
|
1861
|
+
{
|
|
1862
|
+
id: 'sequence-logo',
|
|
1863
|
+
name: 'Sequence logo',
|
|
1864
|
+
kind: 'logo' as const,
|
|
1865
|
+
height: self.sequenceLogoTrackHeight,
|
|
1866
|
+
},
|
|
1867
|
+
// last, so it sits against the alignment it numbers
|
|
1868
|
+
{
|
|
1869
|
+
id: 'position-ruler',
|
|
1870
|
+
name: 'Position',
|
|
1871
|
+
kind: 'ruler' as const,
|
|
1872
|
+
height: 20,
|
|
1873
|
+
},
|
|
1874
|
+
].map(model => ({ model, ReactComponent: TrackBlocks }))
|
|
1428
1875
|
},
|
|
1876
|
+
|
|
1877
|
+
get tracks(): BasicTrack[] {
|
|
1878
|
+
return [
|
|
1879
|
+
...this.adapterTrackModels,
|
|
1880
|
+
...this.basePairTrackModels,
|
|
1881
|
+
...this.columnTrackModels,
|
|
1882
|
+
...this.computedTrackModels,
|
|
1883
|
+
]
|
|
1884
|
+
},
|
|
1885
|
+
|
|
1429
1886
|
/**
|
|
1430
|
-
* #
|
|
1887
|
+
* #getter
|
|
1431
1888
|
*/
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1889
|
+
get turnedOnTracks() {
|
|
1890
|
+
return this.tracks.filter(
|
|
1891
|
+
f =>
|
|
1892
|
+
!trackIsOff(self.turnedOffTracks, f.model.id, f.model.defaultOff),
|
|
1893
|
+
)
|
|
1435
1894
|
},
|
|
1895
|
+
|
|
1436
1896
|
/**
|
|
1437
|
-
* #
|
|
1897
|
+
* #getter
|
|
1438
1898
|
*/
|
|
1439
|
-
|
|
1440
|
-
self.
|
|
1441
|
-
self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
|
|
1899
|
+
get showHorizontalScrollbar() {
|
|
1900
|
+
return overflows(self.totalWidth, self.msaAreaWidth)
|
|
1442
1901
|
},
|
|
1902
|
+
|
|
1443
1903
|
/**
|
|
1444
|
-
* #
|
|
1904
|
+
* #method
|
|
1905
|
+
* Return a row-specific letter at a visible column, or undefined if gap.
|
|
1906
|
+
*
|
|
1907
|
+
* @param rowName - The name of the row
|
|
1908
|
+
* @param visibleCol - The visible column index (what the user sees on screen)
|
|
1909
|
+
* @returns The letter at that position, or undefined if it's a gap
|
|
1445
1910
|
*/
|
|
1446
|
-
|
|
1447
|
-
self.
|
|
1911
|
+
visibleColToRowLetter(rowName: string, visibleCol: number) {
|
|
1912
|
+
return self.rowMap.get(rowName)?.[
|
|
1913
|
+
this.visibleColToGlobalCol(visibleCol)
|
|
1914
|
+
]
|
|
1448
1915
|
},
|
|
1916
|
+
|
|
1449
1917
|
/**
|
|
1450
|
-
* #
|
|
1918
|
+
* #method
|
|
1919
|
+
* Convert a visible column to a row-specific sequence position (0-based).
|
|
1920
|
+
* Returns undefined if the position is a gap in the sequence.
|
|
1921
|
+
*
|
|
1922
|
+
* PUBLIC API: this and the sibling coordinate converters
|
|
1923
|
+
* (visibleColToGlobalCol, seqPosToVisibleCol, globalColToVisibleCol,
|
|
1924
|
+
* seqPosToGlobalCol) are how a host translates between alignment columns
|
|
1925
|
+
* and a row's residue positions across gaps. Keep them stable.
|
|
1926
|
+
*
|
|
1927
|
+
* @param rowName - The name of the row
|
|
1928
|
+
* @param visibleCol - The visible column index
|
|
1929
|
+
* @returns The sequence position (0-based), or undefined if it's a gap
|
|
1451
1930
|
*/
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1931
|
+
visibleColToSeqPos(rowName: string, visibleCol: number) {
|
|
1932
|
+
// a binary search of the row's index, not a scan of the row: this
|
|
1933
|
+
// answers on every mouse move, and a 30k-column row scanned per event
|
|
1934
|
+
// is the whole frame
|
|
1935
|
+
return seqPosOfGlobalCol(
|
|
1936
|
+
self.seqPosIndex(rowName),
|
|
1937
|
+
this.visibleColToGlobalCol(visibleCol),
|
|
1456
1938
|
)
|
|
1457
1939
|
},
|
|
1940
|
+
|
|
1458
1941
|
/**
|
|
1459
|
-
* #
|
|
1942
|
+
* #method
|
|
1943
|
+
* Convert a visible column to a row-specific sequence position (1-based).
|
|
1944
|
+
* Returns undefined if the position is a gap in the sequence.
|
|
1945
|
+
*
|
|
1946
|
+
* @param rowName - The name of the row
|
|
1947
|
+
* @param visibleCol - The visible column index
|
|
1948
|
+
* @returns The sequence position (1-based), or undefined if it's a gap
|
|
1460
1949
|
*/
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
this.zoomInVertical()
|
|
1465
|
-
})
|
|
1950
|
+
visibleColToSeqPosOneBased(rowName: string, visibleCol: number) {
|
|
1951
|
+
const val = this.visibleColToSeqPos(rowName, visibleCol)
|
|
1952
|
+
return val !== undefined ? val + 1 : undefined
|
|
1466
1953
|
},
|
|
1954
|
+
|
|
1467
1955
|
/**
|
|
1468
|
-
* #
|
|
1956
|
+
* #method
|
|
1957
|
+
* Convert a global column index to a visible column index.
|
|
1958
|
+
* Returns undefined if the column is hidden (in blanks).
|
|
1959
|
+
* This is the inverse of visibleColToGlobalCol.
|
|
1960
|
+
*
|
|
1961
|
+
* @param globalCol - The global column index in the full MSA
|
|
1962
|
+
* @returns The visible column index, or undefined if the column is hidden
|
|
1469
1963
|
*/
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
}
|
|
1964
|
+
globalColToVisibleCol(globalCol: number) {
|
|
1965
|
+
const { blanks, hideGapsEffective } = self
|
|
1966
|
+
if (!hideGapsEffective) {
|
|
1967
|
+
return globalCol
|
|
1968
|
+
}
|
|
1969
|
+
return globalColToVisibleCol(blanks, globalCol)
|
|
1475
1970
|
},
|
|
1971
|
+
|
|
1476
1972
|
/**
|
|
1477
|
-
* #
|
|
1478
|
-
*
|
|
1479
|
-
*
|
|
1480
|
-
*
|
|
1481
|
-
*
|
|
1482
|
-
*
|
|
1483
|
-
*
|
|
1484
|
-
*
|
|
1973
|
+
* #method
|
|
1974
|
+
* Convert a visible column index (what a mouse handler reports) back to a
|
|
1975
|
+
* column of the full alignment. Hidden columns shift everything to their
|
|
1976
|
+
* right, so a host that holds per-column data of its own has to make this
|
|
1977
|
+
* hop before indexing it.
|
|
1978
|
+
*
|
|
1979
|
+
* @param visibleCol - The visible column index
|
|
1980
|
+
* @returns The global column index in the full MSA
|
|
1485
1981
|
*/
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1982
|
+
visibleColToGlobalCol(visibleCol: number) {
|
|
1983
|
+
const { blanks, hideGapsEffective } = self
|
|
1984
|
+
return hideGapsEffective
|
|
1985
|
+
? visibleColToGlobalCol(blanks, visibleCol)
|
|
1986
|
+
: visibleCol
|
|
1987
|
+
},
|
|
1988
|
+
|
|
1989
|
+
/**
|
|
1990
|
+
* #method
|
|
1991
|
+
* Convert a sequence position (ungapped) to a global column index.
|
|
1992
|
+
* Returns undefined for a row the alignment does not have -- answering
|
|
1993
|
+
* anyway is how a mistyped or stale row name came to highlight column 0.
|
|
1994
|
+
*
|
|
1995
|
+
* @param rowName - The name of the row
|
|
1996
|
+
* @param seqPos - The sequence position (0-based, ungapped)
|
|
1997
|
+
* @returns The global column index in the full MSA, or undefined
|
|
1998
|
+
*/
|
|
1999
|
+
seqPosToGlobalCol(rowName: string, seqPos: number) {
|
|
2000
|
+
const seq = self.rowMap.get(rowName)
|
|
2001
|
+
if (seq === undefined) {
|
|
2002
|
+
return undefined
|
|
2003
|
+
}
|
|
2004
|
+
const col = self.seqPosIndex(rowName)?.[seqPos]
|
|
2005
|
+
// past the end of the ungapped sequence: 0 for the degenerate all-gap
|
|
2006
|
+
// row, otherwise one past the last column
|
|
2007
|
+
return col ?? (seqPos === 0 ? 0 : seq.length)
|
|
2008
|
+
},
|
|
2009
|
+
|
|
2010
|
+
/**
|
|
2011
|
+
* #method
|
|
2012
|
+
* Convert a sequence position (ungapped) directly to a visible column index.
|
|
2013
|
+
* This combines seqPosToGlobalCol and globalColToVisibleCol.
|
|
2014
|
+
*
|
|
2015
|
+
* @param rowName - The name of the row
|
|
2016
|
+
* @param seqPos - The sequence position (0-based, ungapped)
|
|
2017
|
+
* @returns The visible column index, or undefined if the column is hidden
|
|
2018
|
+
*/
|
|
2019
|
+
seqPosToVisibleCol(rowName: string, seqPos: number) {
|
|
2020
|
+
const globalCol = this.seqPosToGlobalCol(rowName, seqPos)
|
|
2021
|
+
return globalCol === undefined
|
|
2022
|
+
? undefined
|
|
2023
|
+
: this.globalColToVisibleCol(globalCol)
|
|
2024
|
+
},
|
|
2025
|
+
|
|
2026
|
+
/**
|
|
2027
|
+
* #getter
|
|
2028
|
+
* every reason a mapping is being ignored, so a host can say which. A
|
|
2029
|
+
* mapping outlives the alignment it was computed for; when the two no
|
|
2030
|
+
* longer agree the lookups have to refuse, and refusing invisibly is how
|
|
2031
|
+
* "there is no structure here" gets confused with "this data is stale".
|
|
2032
|
+
*/
|
|
2033
|
+
get residueMappingProblems(): ResidueMappingProblem[] {
|
|
2034
|
+
const problems: ResidueMappingProblem[] = []
|
|
2035
|
+
for (const mapping of self.residueMappings) {
|
|
2036
|
+
const where = { row: mapping.row, structureId: mapping.structure.id }
|
|
2037
|
+
const residues = self.seqPosIndex(mapping.row)?.length
|
|
2038
|
+
if (residues === undefined) {
|
|
2039
|
+
problems.push({
|
|
2040
|
+
...where,
|
|
2041
|
+
scope: 'mapping',
|
|
2042
|
+
reason: 'no such row in the alignment',
|
|
2043
|
+
})
|
|
2044
|
+
continue
|
|
2045
|
+
}
|
|
2046
|
+
if (
|
|
2047
|
+
mapping.rowLength !== undefined &&
|
|
2048
|
+
mapping.rowLength !== residues
|
|
2049
|
+
) {
|
|
2050
|
+
problems.push({
|
|
2051
|
+
...where,
|
|
2052
|
+
scope: 'mapping',
|
|
2053
|
+
reason: `computed against a ${mapping.rowLength}-residue row; this one has ${residues}`,
|
|
2054
|
+
})
|
|
2055
|
+
continue
|
|
2056
|
+
}
|
|
2057
|
+
const overrun = mapping.segments.find(
|
|
2058
|
+
segment => segment.rowEnd > residues || segment.rowStart < 1,
|
|
1499
2059
|
)
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
2060
|
+
if (overrun) {
|
|
2061
|
+
problems.push({
|
|
2062
|
+
...where,
|
|
2063
|
+
scope: 'mapping',
|
|
2064
|
+
reason: `segment ${overrun.rowStart}-${overrun.rowEnd} does not fit a ${residues}-residue row`,
|
|
2065
|
+
})
|
|
2066
|
+
continue
|
|
2067
|
+
}
|
|
2068
|
+
for (const segment of mapping.segments) {
|
|
2069
|
+
if (!sameLength(segment)) {
|
|
2070
|
+
problems.push({
|
|
2071
|
+
...where,
|
|
2072
|
+
scope: 'segment',
|
|
2073
|
+
reason: `segment ${segment.rowStart}-${segment.rowEnd} maps to ${segment.structStart}-${segment.structEnd}, which is a different length`,
|
|
2074
|
+
})
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
return problems
|
|
2079
|
+
},
|
|
2080
|
+
|
|
2081
|
+
/**
|
|
2082
|
+
* #getter
|
|
2083
|
+
* the mappings that still fit the loaded alignment. A row-level problem
|
|
2084
|
+
* takes the whole mapping out; a single malformed segment takes only
|
|
2085
|
+
* itself, since the rest of the mapping is still a claim about residues
|
|
2086
|
+
* that exist.
|
|
2087
|
+
*/
|
|
2088
|
+
get usableResidueMappings(): ResidueMapping[] {
|
|
2089
|
+
const unusable = new Set(
|
|
2090
|
+
this.residueMappingProblems
|
|
2091
|
+
.filter(problem => problem.scope === 'mapping')
|
|
2092
|
+
.map(problem => `${problem.row}\u0000${problem.structureId}`),
|
|
2093
|
+
)
|
|
2094
|
+
return self.residueMappings
|
|
2095
|
+
.filter(m => !unusable.has(`${m.row}\u0000${m.structure.id}`))
|
|
2096
|
+
.map(m => ({
|
|
2097
|
+
...m,
|
|
2098
|
+
segments: m.segments.filter(sameLength),
|
|
2099
|
+
}))
|
|
2100
|
+
},
|
|
2101
|
+
|
|
2102
|
+
/**
|
|
2103
|
+
* #getter
|
|
2104
|
+
* the structures the loaded alignment has usable mappings onto. A row can
|
|
2105
|
+
* have several -- an experimental entry and a predicted model, say -- so
|
|
2106
|
+
* a host that means a particular one has to name it.
|
|
2107
|
+
*/
|
|
2108
|
+
get mappedStructures() {
|
|
2109
|
+
return this.usableResidueMappings.map(m => ({
|
|
2110
|
+
row: m.row,
|
|
2111
|
+
structure: m.structure,
|
|
2112
|
+
}))
|
|
2113
|
+
},
|
|
2114
|
+
|
|
2115
|
+
/**
|
|
2116
|
+
* #method
|
|
2117
|
+
* The structure residue a row residue is, or undefined. Refusing is the
|
|
2118
|
+
* point: the guess this replaces answered every query, with a wrong
|
|
2119
|
+
* residue when it did not know.
|
|
2120
|
+
*
|
|
2121
|
+
* It also refuses when the answer is not unique. A row commonly maps onto
|
|
2122
|
+
* several structures -- an experimental entry and two predicted models --
|
|
2123
|
+
* and returning whichever came first would be the same class of wrong,
|
|
2124
|
+
* quieter. Name one with `structureId`, or use `mappedStructures` to see
|
|
2125
|
+
* what there is.
|
|
2126
|
+
*
|
|
2127
|
+
* Positions are 1-based, as `residueMappings` and `highlights` are --
|
|
2128
|
+
* note that the column helpers above take 0-based ones.
|
|
2129
|
+
*
|
|
2130
|
+
* @param rowName - The alignment row
|
|
2131
|
+
* @param seqPos - Residue of that row, 1-based
|
|
2132
|
+
* @param structureId - Which structure, when the row maps onto several
|
|
2133
|
+
*/
|
|
2134
|
+
structureResidue(
|
|
2135
|
+
rowName: string,
|
|
2136
|
+
seqPos: number,
|
|
2137
|
+
structureId?: string,
|
|
2138
|
+
): StructureResidue | undefined {
|
|
2139
|
+
const hits: StructureResidue[] = []
|
|
2140
|
+
for (const mapping of this.usableResidueMappings) {
|
|
2141
|
+
if (mapping.row !== rowName) {
|
|
2142
|
+
continue
|
|
2143
|
+
}
|
|
2144
|
+
if (
|
|
2145
|
+
structureId !== undefined &&
|
|
2146
|
+
mapping.structure.id !== structureId
|
|
2147
|
+
) {
|
|
2148
|
+
continue
|
|
2149
|
+
}
|
|
2150
|
+
const segment = mapping.segments.find(
|
|
2151
|
+
seg => seqPos >= seg.rowStart && seqPos <= seg.rowEnd,
|
|
1504
2152
|
)
|
|
2153
|
+
if (segment) {
|
|
2154
|
+
const position = segment.structStart + (seqPos - segment.rowStart)
|
|
2155
|
+
hits.push({
|
|
2156
|
+
structure: mapping.structure,
|
|
2157
|
+
position,
|
|
2158
|
+
observed: !inRanges(mapping.unobserved, position),
|
|
2159
|
+
})
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
return hits.length === 1 ? hits[0] : undefined
|
|
2163
|
+
},
|
|
1505
2164
|
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
2165
|
+
/**
|
|
2166
|
+
* #method
|
|
2167
|
+
* The row residue a structure residue is, the same lookup backwards, and
|
|
2168
|
+
* refusing on the same terms. `asymId` picks between mappings onto the
|
|
2169
|
+
* same entry, which a homodimer -- two rows, two chains, one id -- always
|
|
2170
|
+
* needs; without it such a lookup is ambiguous and gets nothing.
|
|
2171
|
+
*
|
|
2172
|
+
* @param structureId - The structure's id, as the mapping names it
|
|
2173
|
+
* @param position - Residue of that structure, 1-based label_seq_id
|
|
2174
|
+
* @param asymId - Which chain, when the id alone is ambiguous
|
|
2175
|
+
*/
|
|
2176
|
+
rowResidue(
|
|
2177
|
+
structureId: string,
|
|
2178
|
+
position: number,
|
|
2179
|
+
asymId?: string,
|
|
2180
|
+
): RowResidue | undefined {
|
|
2181
|
+
const hits: RowResidue[] = []
|
|
2182
|
+
for (const mapping of this.usableResidueMappings) {
|
|
2183
|
+
if (mapping.structure.id !== structureId) {
|
|
2184
|
+
continue
|
|
2185
|
+
}
|
|
2186
|
+
if (asymId !== undefined && mapping.structure.asymId !== asymId) {
|
|
2187
|
+
continue
|
|
2188
|
+
}
|
|
2189
|
+
const segment = mapping.segments.find(
|
|
2190
|
+
seg => position >= seg.structStart && position <= seg.structEnd,
|
|
1518
2191
|
)
|
|
1519
|
-
|
|
2192
|
+
if (segment) {
|
|
2193
|
+
hits.push({
|
|
2194
|
+
rowName: mapping.row,
|
|
2195
|
+
seqPos: segment.rowStart + (position - segment.structStart),
|
|
2196
|
+
})
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
return hits.length === 1 ? hits[0] : undefined
|
|
2200
|
+
},
|
|
2201
|
+
}))
|
|
2202
|
+
|
|
2203
|
+
.views(self => ({
|
|
2204
|
+
/**
|
|
2205
|
+
* #getter
|
|
2206
|
+
* the vertical space the alignment rows actually get: the widget height
|
|
2207
|
+
* less everything stacked above and below them -- the header, the tracks,
|
|
2208
|
+
* and the minimap when the columns overflow. Every consumer wants this
|
|
2209
|
+
* same subtraction, so there is one of it: blocksY, maxScrollY, the
|
|
2210
|
+
* vertical scrollbar and fitVertically all read it, and a second getter
|
|
2211
|
+
* that forgot the tracks is what put the last rows out of reach.
|
|
2212
|
+
*/
|
|
2213
|
+
get msaAreaHeight() {
|
|
2214
|
+
return (
|
|
2215
|
+
self.height -
|
|
2216
|
+
(self.showHorizontalScrollbar ? self.minimapHeight : 0) -
|
|
2217
|
+
self.headerHeight -
|
|
2218
|
+
this.totalTrackAreaHeight
|
|
2219
|
+
)
|
|
2220
|
+
},
|
|
2221
|
+
/**
|
|
2222
|
+
* #getter
|
|
2223
|
+
* total height of track area (px)
|
|
2224
|
+
*/
|
|
2225
|
+
get totalTrackAreaHeight() {
|
|
2226
|
+
return sum(self.turnedOnTracks.map(r => r.model.height))
|
|
2227
|
+
},
|
|
2228
|
+
/**
|
|
2229
|
+
* one representative annotation per accession, which is what the legend,
|
|
2230
|
+
* the filter dialog and the palettes key off
|
|
2231
|
+
*/
|
|
2232
|
+
get annotationTypes() {
|
|
2233
|
+
// first occurrence wins. The representative supplies only the name,
|
|
2234
|
+
// description and -- for ordinal segments -- the start that orders
|
|
2235
|
+
// them, and those agree across an accession's instances
|
|
2236
|
+
const types = new Map<string, Annotation>()
|
|
2237
|
+
for (const annot of self.annotations) {
|
|
2238
|
+
if (!types.has(annot.accession)) {
|
|
2239
|
+
types.set(annot.accession, annot)
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
return types
|
|
2243
|
+
},
|
|
2244
|
+
get filteredAnnotations() {
|
|
2245
|
+
return self.annotations.filter(
|
|
2246
|
+
r => !self.turnedOffFeatures.get(r.accession),
|
|
2247
|
+
)
|
|
2248
|
+
},
|
|
2249
|
+
get annotationsByRow() {
|
|
2250
|
+
return groupBy(this.filteredAnnotations, r => r.id)
|
|
1520
2251
|
},
|
|
2252
|
+
}))
|
|
2253
|
+
.views(self => ({
|
|
1521
2254
|
/**
|
|
1522
|
-
* #
|
|
2255
|
+
* #getter
|
|
1523
2256
|
*/
|
|
1524
|
-
|
|
1525
|
-
|
|
2257
|
+
get showVerticalScrollbar() {
|
|
2258
|
+
return overflows(self.totalHeight, self.msaAreaHeight)
|
|
1526
2259
|
},
|
|
1527
|
-
|
|
2260
|
+
}))
|
|
2261
|
+
.views(self => ({
|
|
1528
2262
|
/**
|
|
1529
|
-
* #
|
|
1530
|
-
* set scroll Y-offset (px), clamped to keep the alignment in view
|
|
2263
|
+
* #getter
|
|
1531
2264
|
*/
|
|
1532
|
-
|
|
1533
|
-
|
|
2265
|
+
get dataInitialized() {
|
|
2266
|
+
// truthiness, not `!== ''`: these are types.maybe, and DataModel's
|
|
2267
|
+
// postProcessSnapshot drops a document over 50kb, so a restored session
|
|
2268
|
+
// that inlined a large alignment comes back `undefined` here -- which
|
|
2269
|
+
// `!== ''` reads as initialized and renders an empty view instead of
|
|
2270
|
+
// the import form
|
|
2271
|
+
return !!(self.data.msa || self.data.tree) && !self.error
|
|
1534
2272
|
},
|
|
1535
|
-
|
|
1536
2273
|
/**
|
|
1537
|
-
* #
|
|
1538
|
-
* Set the overlay annotations and reveal the overlay in a single step (an
|
|
1539
|
-
* empty list clears both). Every source funnels through here after its
|
|
1540
|
-
* own adapter has flattened it: InterProScan, GFF, user uploads, NCBI CDD.
|
|
2274
|
+
* #getter
|
|
1541
2275
|
*/
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
2276
|
+
get blocksX() {
|
|
2277
|
+
return calculateBlocks({
|
|
2278
|
+
viewportSize: self.msaAreaWidth,
|
|
2279
|
+
viewportPos: -self.scrollX,
|
|
2280
|
+
blockSize: self.blockSize,
|
|
2281
|
+
mapSize: self.totalWidth,
|
|
2282
|
+
})
|
|
1545
2283
|
},
|
|
1546
|
-
|
|
1547
2284
|
/**
|
|
1548
|
-
* #
|
|
1549
|
-
* set the overlay from raw InterProScan results keyed by row name. Kept
|
|
1550
|
-
* for downstream plugins that hold the EBI wire format; new code should
|
|
1551
|
-
* adapt to Annotation[] and call setAnnotations.
|
|
2285
|
+
* #getter
|
|
1552
2286
|
*/
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
2287
|
+
get blocksY() {
|
|
2288
|
+
return calculateBlocks({
|
|
2289
|
+
viewportSize: self.msaAreaHeight,
|
|
2290
|
+
viewportPos: -self.scrollY,
|
|
2291
|
+
blockSize: self.blockSize,
|
|
2292
|
+
mapSize: self.totalHeight,
|
|
2293
|
+
})
|
|
1559
2294
|
},
|
|
1560
|
-
|
|
2295
|
+
}))
|
|
2296
|
+
.views(self => ({
|
|
1561
2297
|
/**
|
|
1562
|
-
* #
|
|
2298
|
+
* #getter
|
|
1563
2299
|
*/
|
|
1564
|
-
|
|
1565
|
-
|
|
2300
|
+
get blocks2d() {
|
|
2301
|
+
return self.blocksY.flatMap(by =>
|
|
2302
|
+
self.blocksX.map(bx => [bx, by] as const),
|
|
2303
|
+
)
|
|
1566
2304
|
},
|
|
1567
2305
|
|
|
1568
2306
|
/**
|
|
1569
|
-
* #
|
|
2307
|
+
* #getter
|
|
1570
2308
|
*/
|
|
1571
|
-
|
|
1572
|
-
self.
|
|
2309
|
+
get isLoading() {
|
|
2310
|
+
return self.loadingMSA || self.loadingTree
|
|
1573
2311
|
},
|
|
1574
|
-
|
|
1575
2312
|
/**
|
|
1576
|
-
* #
|
|
2313
|
+
* #getter
|
|
1577
2314
|
*/
|
|
1578
|
-
|
|
1579
|
-
self.
|
|
2315
|
+
get maxScrollX() {
|
|
2316
|
+
return Math.min(-self.totalWidth + (self.msaAreaWidth - 100), 0)
|
|
1580
2317
|
},
|
|
1581
2318
|
/**
|
|
1582
|
-
* #
|
|
2319
|
+
* #getter
|
|
2320
|
+
* most-negative allowed scrollY, keeping the last row in view rather than
|
|
2321
|
+
* letting the whole alignment scroll off the top.
|
|
1583
2322
|
*/
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
// what the flipped entry should hold
|
|
1587
|
-
self.turnedOffTracks.set(id, !trackIsOff(self.turnedOffTracks, id))
|
|
2323
|
+
get maxScrollY() {
|
|
2324
|
+
return Math.min(-self.totalHeight + self.msaAreaHeight, 0)
|
|
1588
2325
|
},
|
|
2326
|
+
}))
|
|
2327
|
+
.actions(self => ({
|
|
1589
2328
|
/**
|
|
1590
2329
|
* #action
|
|
1591
2330
|
*/
|
|
1592
|
-
|
|
1593
|
-
self.
|
|
2331
|
+
setDrawMsaLetters(arg: boolean) {
|
|
2332
|
+
self.drawMsaLetters = arg
|
|
1594
2333
|
},
|
|
1595
|
-
|
|
1596
|
-
.views(self => ({
|
|
2334
|
+
|
|
1597
2335
|
/**
|
|
1598
|
-
* #
|
|
2336
|
+
* #action
|
|
1599
2337
|
*/
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
// gated on the same condition the renderer draws labels under, so the
|
|
1603
|
-
// gutter labelsWidth reserves and the labels actually drawn cannot
|
|
1604
|
-
// disagree -- and so turning labels off hands their space to the tree.
|
|
1605
|
-
// Measured at a fixed reference size and scaled by labelWidthScale:
|
|
1606
|
-
// re-measuring every leaf on every vertical-zoom frame cost ~200ms on a
|
|
1607
|
-
// 50k-leaf tree
|
|
1608
|
-
return showTreeText
|
|
1609
|
-
? new Map(
|
|
1610
|
-
leaves.map(node => {
|
|
1611
|
-
const { name } = node.data
|
|
1612
|
-
// `||`, matching renderTreeLabels: an empty genome falls back
|
|
1613
|
-
// to the row name, and measuring '' would size the gutter (and
|
|
1614
|
-
// the label's click target) to nothing
|
|
1615
|
-
const displayName = treeMetadata[name]?.genome || name
|
|
1616
|
-
return [
|
|
1617
|
-
name,
|
|
1618
|
-
measureTextCanvas(displayName, labelReferenceFontSize),
|
|
1619
|
-
] as const
|
|
1620
|
-
}),
|
|
1621
|
-
)
|
|
1622
|
-
: new Map<string, number>()
|
|
2338
|
+
setScrollZoom(arg: boolean) {
|
|
2339
|
+
self.scrollZoom = arg
|
|
1623
2340
|
},
|
|
1624
2341
|
|
|
1625
2342
|
/**
|
|
1626
|
-
* #
|
|
1627
|
-
*
|
|
1628
|
-
* font size
|
|
2343
|
+
* #action
|
|
2344
|
+
* set hovered tree node and its descendants
|
|
1629
2345
|
*/
|
|
1630
|
-
|
|
1631
|
-
|
|
2346
|
+
setHoveredTreeNode(nodeId?: string) {
|
|
2347
|
+
// the tree's mousemove handler calls this on every event, and both the
|
|
2348
|
+
// lookup and the write are expensive: `find` walks the whole hierarchy,
|
|
2349
|
+
// and a fresh object here invalidates hoveredRowIndices and redraws the
|
|
2350
|
+
// tree and MSA overlays. Re-hovering the same node is the common case
|
|
2351
|
+
if (nodeId === self.hoveredTreeNode?.nodeId) {
|
|
2352
|
+
return
|
|
2353
|
+
}
|
|
2354
|
+
if (!nodeId) {
|
|
2355
|
+
self.hoveredTreeNode = undefined
|
|
2356
|
+
return
|
|
2357
|
+
}
|
|
2358
|
+
const node = find(self.hierarchy, n => n.data.id === nodeId)
|
|
2359
|
+
self.hoveredTreeNode = node
|
|
2360
|
+
? {
|
|
2361
|
+
nodeId,
|
|
2362
|
+
descendantNames: leaves(node).map(leaf => leaf.data.name),
|
|
2363
|
+
}
|
|
2364
|
+
: undefined
|
|
1632
2365
|
},
|
|
1633
2366
|
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
2367
|
+
/**
|
|
2368
|
+
* #action
|
|
2369
|
+
* Calculate a neighbor joining tree from the current MSA using BLOSUM62
|
|
2370
|
+
* distances. Refuses above `maxNeighborJoiningRows`: the join loop is
|
|
2371
|
+
* cubic and runs on the main thread, so 800 rows is a ten-second freeze
|
|
2372
|
+
* with no progress and no cancel, and a tree that size wants a tool built
|
|
2373
|
+
* for it anyway.
|
|
2374
|
+
*/
|
|
2375
|
+
calculateNeighborJoiningTreeFromMSA() {
|
|
2376
|
+
// every sequence in the alignment, not the rows on screen: a collapsed
|
|
2377
|
+
// clade is a display state, and building the tree from what it leaves
|
|
2378
|
+
// showing drops the sequences it hides out of the result
|
|
2379
|
+
const rows = [...self.rowMap]
|
|
2380
|
+
if (rows.length < 2) {
|
|
2381
|
+
throw new Error('Need at least 2 sequences to build a tree')
|
|
1645
2382
|
}
|
|
1646
|
-
|
|
2383
|
+
if (rows.length > maxNeighborJoiningRows) {
|
|
2384
|
+
throw new Error(
|
|
2385
|
+
`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`,
|
|
2386
|
+
)
|
|
2387
|
+
}
|
|
2388
|
+
this.replaceTree(calculateNeighborJoiningTree(rows))
|
|
1647
2389
|
},
|
|
1648
2390
|
|
|
1649
2391
|
/**
|
|
1650
|
-
* #
|
|
2392
|
+
* #action
|
|
2393
|
+
* swap in a different tree over the same alignment. Node ids are derived
|
|
2394
|
+
* from the path (node-0-0-1), so a `collapsed` or `showOnly` id held over
|
|
2395
|
+
* from the old tree matches a real node in the new one and folds whatever
|
|
2396
|
+
* happens to sit there -- the ids go with the tree they name.
|
|
1651
2397
|
*/
|
|
1652
|
-
|
|
1653
|
-
|
|
2398
|
+
replaceTree(newick: string) {
|
|
2399
|
+
transaction(() => {
|
|
2400
|
+
self.collapsed.clear()
|
|
2401
|
+
self.setShowOnly(undefined)
|
|
2402
|
+
self.setTree(newick)
|
|
2403
|
+
})
|
|
1654
2404
|
},
|
|
1655
2405
|
|
|
1656
2406
|
/**
|
|
1657
|
-
* #
|
|
2407
|
+
* #action
|
|
2408
|
+
* restore the default column width and row height
|
|
1658
2409
|
*/
|
|
1659
|
-
|
|
1660
|
-
|
|
2410
|
+
resetZoom() {
|
|
2411
|
+
self.setColWidth(defaultColWidth)
|
|
2412
|
+
self.setRowHeight(defaultRowHeight)
|
|
1661
2413
|
},
|
|
1662
|
-
|
|
1663
2414
|
/**
|
|
1664
|
-
* #
|
|
2415
|
+
* #action
|
|
1665
2416
|
*/
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
return tracks
|
|
1670
|
-
.filter(t => !!t.data)
|
|
1671
|
-
.map(t => ({
|
|
1672
|
-
model: {
|
|
1673
|
-
...t,
|
|
1674
|
-
kind: 'text' as const,
|
|
1675
|
-
data: hideGapsEffective ? skipBlanks(blanks, t.data!) : t.data,
|
|
1676
|
-
height: rowHeight,
|
|
1677
|
-
},
|
|
1678
|
-
ReactComponent: TrackBlocks,
|
|
1679
|
-
}))
|
|
2417
|
+
zoomOutHorizontal() {
|
|
2418
|
+
self.colWidth = Math.max(minColWidth, Math.floor(self.colWidth * 0.75))
|
|
2419
|
+
self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
|
|
1680
2420
|
},
|
|
1681
|
-
|
|
1682
2421
|
/**
|
|
1683
|
-
* #
|
|
1684
|
-
* a data track's values or string, projected from its row's residues
|
|
1685
|
-
* onto alignment columns when it names a row
|
|
2422
|
+
* #action
|
|
1686
2423
|
*/
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
const project = <T>(track: ColumnTrackSpec, items: T[], fill: T) => {
|
|
1691
|
-
if (!track.row) {
|
|
1692
|
-
return items
|
|
1693
|
-
}
|
|
1694
|
-
const out = Array.from({ length: width }, () => fill)
|
|
1695
|
-
const index = self.seqPosGlobalColIndex.get(track.row)
|
|
1696
|
-
items.forEach((item, seqPos) => {
|
|
1697
|
-
const col = index?.[seqPos]
|
|
1698
|
-
if (col !== undefined) {
|
|
1699
|
-
out[col] = item
|
|
1700
|
-
}
|
|
1701
|
-
})
|
|
1702
|
-
return out
|
|
1703
|
-
}
|
|
1704
|
-
const skip = <T>(items: T[]) =>
|
|
1705
|
-
hideGapsEffective ? dropBlanks(blanks, items) : items
|
|
1706
|
-
return new Map<string, { values?: number[]; data?: string }>(
|
|
1707
|
-
self.columnTracks.map(track => {
|
|
1708
|
-
if (track.kind === 'bar') {
|
|
1709
|
-
const max = track.max ?? 1
|
|
1710
|
-
const values = skip(project(track, track.values ?? [], 0)).map(
|
|
1711
|
-
v => Math.min(1, Math.max(0, v / max)),
|
|
1712
|
-
)
|
|
1713
|
-
return [track.id, { values }] as const
|
|
1714
|
-
}
|
|
1715
|
-
const data = skip(project(track, (track.data ?? '').split(''), ' '))
|
|
1716
|
-
return [track.id, { data: data.join('') }] as const
|
|
1717
|
-
}),
|
|
1718
|
-
)
|
|
2424
|
+
zoomInHorizontal() {
|
|
2425
|
+
self.colWidth = Math.min(maxCellSize, Math.ceil(self.colWidth * 1.5))
|
|
2426
|
+
self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
|
|
1719
2427
|
},
|
|
1720
2428
|
/**
|
|
1721
|
-
* #
|
|
2429
|
+
* #action
|
|
1722
2430
|
*/
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
barColor: track.color,
|
|
1735
|
-
customColorScheme: track.colors,
|
|
1736
|
-
data: this.columnTrackContent.get(track.id)?.data,
|
|
1737
|
-
},
|
|
1738
|
-
ReactComponent: TrackBlocks,
|
|
1739
|
-
}))
|
|
2431
|
+
zoomInVertical() {
|
|
2432
|
+
self.rowHeight = Math.min(maxCellSize, Math.ceil(self.rowHeight * 1.5))
|
|
2433
|
+
},
|
|
2434
|
+
/**
|
|
2435
|
+
* #action
|
|
2436
|
+
*/
|
|
2437
|
+
zoomOutVertical() {
|
|
2438
|
+
self.rowHeight = Math.max(
|
|
2439
|
+
minRowHeight,
|
|
2440
|
+
Math.floor(self.rowHeight * 0.75),
|
|
2441
|
+
)
|
|
1740
2442
|
},
|
|
1741
2443
|
/**
|
|
1742
|
-
* #
|
|
2444
|
+
* #action
|
|
1743
2445
|
*/
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
height: self.conservationTrackHeight,
|
|
1750
|
-
barColor: 'gray',
|
|
1751
|
-
}
|
|
1752
|
-
const propertyConservationTrack = {
|
|
1753
|
-
id: 'property-conservation',
|
|
1754
|
-
name: 'Property conservation',
|
|
1755
|
-
kind: 'bar' as const,
|
|
1756
|
-
height: self.conservationTrackHeight,
|
|
1757
|
-
barColor: '#6a51a3',
|
|
1758
|
-
}
|
|
1759
|
-
const sequenceLogoTrack = {
|
|
1760
|
-
id: 'sequence-logo',
|
|
1761
|
-
name: 'Sequence logo',
|
|
1762
|
-
kind: 'logo' as const,
|
|
1763
|
-
height: self.sequenceLogoTrackHeight,
|
|
1764
|
-
}
|
|
1765
|
-
return [
|
|
1766
|
-
...this.adapterTrackModels,
|
|
1767
|
-
...this.columnTrackModels,
|
|
1768
|
-
...[
|
|
1769
|
-
conservationTrack,
|
|
1770
|
-
...(self.sequenceType === 'amino'
|
|
1771
|
-
? [propertyConservationTrack]
|
|
1772
|
-
: []),
|
|
1773
|
-
sequenceLogoTrack,
|
|
1774
|
-
].map(model => ({ model, ReactComponent: TrackBlocks })),
|
|
1775
|
-
]
|
|
2446
|
+
zoomIn() {
|
|
2447
|
+
transaction(() => {
|
|
2448
|
+
this.zoomInHorizontal()
|
|
2449
|
+
this.zoomInVertical()
|
|
2450
|
+
})
|
|
1776
2451
|
},
|
|
1777
|
-
|
|
1778
2452
|
/**
|
|
1779
|
-
* #
|
|
2453
|
+
* #action
|
|
1780
2454
|
*/
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
2455
|
+
zoomOut() {
|
|
2456
|
+
transaction(() => {
|
|
2457
|
+
this.zoomOutHorizontal()
|
|
2458
|
+
this.zoomOutVertical()
|
|
2459
|
+
})
|
|
1785
2460
|
},
|
|
1786
|
-
|
|
1787
2461
|
/**
|
|
1788
|
-
* #
|
|
2462
|
+
* #action
|
|
2463
|
+
* Smoothly zoom by a continuous scaleFactor. The column under the cursor
|
|
2464
|
+
* (offsetX/offsetY, px relative to the MSA area) stays anchored
|
|
2465
|
+
* horizontally. Vertically the anchor is biased toward the top: when the
|
|
2466
|
+
* alignment nearly fits the viewport, snap to y=0 rather than pinning a
|
|
2467
|
+
* random row under the cursor, with the bias fading out as the alignment
|
|
2468
|
+
* grows taller than the viewport (where cursor-anchoring is useful).
|
|
2469
|
+
* Drives wheel/trackpad-pinch zoom.
|
|
1789
2470
|
*/
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
2471
|
+
zoomToPos(scaleFactor: number, offsetX: number, offsetY: number) {
|
|
2472
|
+
transaction(() => {
|
|
2473
|
+
const colInView = (-self.scrollX + offsetX) / self.colWidth
|
|
2474
|
+
const rowInView = (-self.scrollY + offsetY) / self.rowHeight
|
|
2475
|
+
self.colWidth = clamp(
|
|
2476
|
+
self.colWidth * scaleFactor,
|
|
2477
|
+
minColWidth,
|
|
2478
|
+
maxCellSize,
|
|
2479
|
+
)
|
|
2480
|
+
self.rowHeight = clamp(
|
|
2481
|
+
self.rowHeight * scaleFactor,
|
|
2482
|
+
minRowHeight,
|
|
2483
|
+
maxCellSize,
|
|
2484
|
+
)
|
|
2485
|
+
self.scrollX = clamp(
|
|
2486
|
+
offsetX - colInView * self.colWidth,
|
|
2487
|
+
self.maxScrollX,
|
|
2488
|
+
0,
|
|
2489
|
+
)
|
|
1793
2490
|
|
|
2491
|
+
const anchoredScrollY = offsetY - rowInView * self.rowHeight
|
|
2492
|
+
// maxScrollY is -(totalHeight - visibleMsaHeight) when the alignment
|
|
2493
|
+
// overflows, so -maxScrollY is exactly that overflow past the
|
|
2494
|
+
// scrollable MSA viewport (0 when it fits)
|
|
2495
|
+
const overflow = Math.max(0, -self.maxScrollY)
|
|
2496
|
+
const visibleHeight = self.totalHeight - overflow
|
|
2497
|
+
const topBias =
|
|
2498
|
+
visibleHeight > 0 ? clamp(1 - overflow / visibleHeight, 0, 1) : 1
|
|
2499
|
+
self.scrollY = clamp(
|
|
2500
|
+
anchoredScrollY * (1 - topBias),
|
|
2501
|
+
self.maxScrollY,
|
|
2502
|
+
0,
|
|
2503
|
+
)
|
|
2504
|
+
})
|
|
2505
|
+
},
|
|
1794
2506
|
/**
|
|
1795
|
-
* #
|
|
1796
|
-
* Return a row-specific letter at a visible column, or undefined if gap.
|
|
1797
|
-
*
|
|
1798
|
-
* @param rowName - The name of the row
|
|
1799
|
-
* @param visibleCol - The visible column index (what the user sees on screen)
|
|
1800
|
-
* @returns The letter at that position, or undefined if it's a gap
|
|
2507
|
+
* #action
|
|
1801
2508
|
*/
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
return rowMap.get(rowName)?.[visibleColToGlobalCol(blanks, visibleCol)]
|
|
2509
|
+
doScrollY(deltaY: number) {
|
|
2510
|
+
this.setScrollY(self.scrollY + deltaY)
|
|
1805
2511
|
},
|
|
1806
2512
|
|
|
1807
2513
|
/**
|
|
1808
|
-
* #
|
|
1809
|
-
*
|
|
1810
|
-
* Returns undefined if the position is a gap in the sequence.
|
|
1811
|
-
*
|
|
1812
|
-
* CROSS-REPO CONTRACT: this and the sibling coordinate converters
|
|
1813
|
-
* (seqPosToVisibleCol, globalColToVisibleCol, seqPosToGlobalCol) are used
|
|
1814
|
-
* by jbrowse-plugin-protein3d to translate between alignment columns and
|
|
1815
|
-
* structure/sequence residue positions across gaps. Keep them stable.
|
|
1816
|
-
*
|
|
1817
|
-
* @param rowName - The name of the row
|
|
1818
|
-
* @param visibleCol - The visible column index
|
|
1819
|
-
* @returns The sequence position (0-based), or undefined if it's a gap
|
|
2514
|
+
* #action
|
|
2515
|
+
* set scroll Y-offset (px), clamped to keep the alignment in view
|
|
1820
2516
|
*/
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
rowName,
|
|
1824
|
-
visibleCol,
|
|
1825
|
-
rowMap: self.rowMap,
|
|
1826
|
-
blanks: self.blanks,
|
|
1827
|
-
})
|
|
2517
|
+
setScrollY(n: number) {
|
|
2518
|
+
self.scrollY = clamp(n, self.maxScrollY, 0)
|
|
1828
2519
|
},
|
|
1829
2520
|
|
|
1830
2521
|
/**
|
|
1831
|
-
* #
|
|
1832
|
-
*
|
|
1833
|
-
*
|
|
2522
|
+
* #action
|
|
2523
|
+
* Set the overlay annotations (an empty list clears them). Every source
|
|
2524
|
+
* funnels through here after its own adapter has flattened it:
|
|
2525
|
+
* InterProScan, GFF, user uploads, NCBI CDD.
|
|
1834
2526
|
*
|
|
1835
|
-
*
|
|
1836
|
-
*
|
|
1837
|
-
*
|
|
2527
|
+
* It does not touch `showDomains`. Loading used to force the overlay on,
|
|
2528
|
+
* and since a restored snapshot loads its GFF again on the way in, a link
|
|
2529
|
+
* shared with the overlay hidden reopened with it drawn.
|
|
1838
2530
|
*/
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
return val !== undefined ? val + 1 : undefined
|
|
2531
|
+
setAnnotations(annotations: Annotation[]) {
|
|
2532
|
+
self.annotations = annotations
|
|
1842
2533
|
},
|
|
1843
2534
|
|
|
1844
2535
|
/**
|
|
1845
|
-
* #
|
|
1846
|
-
*
|
|
1847
|
-
*
|
|
1848
|
-
*
|
|
1849
|
-
*
|
|
1850
|
-
* @param globalCol - The global column index in the full MSA
|
|
1851
|
-
* @returns The visible column index, or undefined if the column is hidden
|
|
2536
|
+
* #action
|
|
2537
|
+
* set the overlay from raw InterProScan results keyed by row name. Kept
|
|
2538
|
+
* for downstream plugins that hold the EBI wire format; new code should
|
|
2539
|
+
* adapt to Annotation[] and call setAnnotations.
|
|
1852
2540
|
*/
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
2541
|
+
setDomains(data?: Record<string, InterProScanResults>) {
|
|
2542
|
+
this.setAnnotations(data ? interProScanToAnnotations(data) : [])
|
|
2543
|
+
},
|
|
2544
|
+
|
|
2545
|
+
applyGFFText(gffText: string) {
|
|
2546
|
+
this.setAnnotations(gffToAnnotations(parseGFF(gffText)))
|
|
1859
2547
|
},
|
|
1860
2548
|
|
|
1861
2549
|
/**
|
|
1862
|
-
* #
|
|
1863
|
-
* Convert a sequence position (ungapped) to a global column index.
|
|
1864
|
-
*
|
|
1865
|
-
* @param rowName - The name of the row
|
|
1866
|
-
* @param seqPos - The sequence position (0-based, ungapped)
|
|
1867
|
-
* @returns The global column index in the full MSA
|
|
2550
|
+
* #action
|
|
1868
2551
|
*/
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
if (seq === undefined) {
|
|
1872
|
-
return 0
|
|
1873
|
-
}
|
|
1874
|
-
const col = self.seqPosGlobalColIndex.get(rowName)?.[seqPos]
|
|
1875
|
-
// past the end of the ungapped sequence: 0 for the degenerate all-gap
|
|
1876
|
-
// row, otherwise one past the last column
|
|
1877
|
-
return col ?? (seqPos === 0 ? 0 : seq.length)
|
|
2552
|
+
doScrollX(deltaX: number) {
|
|
2553
|
+
this.setScrollX(self.scrollX + deltaX)
|
|
1878
2554
|
},
|
|
1879
2555
|
|
|
1880
2556
|
/**
|
|
1881
|
-
* #
|
|
1882
|
-
* Convert a sequence position (ungapped) directly to a visible column index.
|
|
1883
|
-
* This combines seqPosToGlobalCol and globalColToVisibleCol.
|
|
1884
|
-
*
|
|
1885
|
-
* @param rowName - The name of the row
|
|
1886
|
-
* @param seqPos - The sequence position (0-based, ungapped)
|
|
1887
|
-
* @returns The visible column index, or undefined if the column is hidden
|
|
2557
|
+
* #action
|
|
1888
2558
|
*/
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
return this.globalColToVisibleCol(globalCol)
|
|
2559
|
+
setScrollX(n: number) {
|
|
2560
|
+
self.scrollX = clamp(n, self.maxScrollX, 0)
|
|
1892
2561
|
},
|
|
1893
|
-
}))
|
|
1894
2562
|
|
|
1895
|
-
.views(self => ({
|
|
1896
2563
|
/**
|
|
1897
|
-
* #
|
|
1898
|
-
* widget width minus the tree area gives the space for the MSA
|
|
2564
|
+
* #action
|
|
1899
2565
|
*/
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
self.height -
|
|
1903
|
-
(self.showHorizontalScrollbar ? self.minimapHeight : 0) -
|
|
1904
|
-
self.headerHeight
|
|
1905
|
-
)
|
|
2566
|
+
setColumnTracks(tracks: ColumnTrackSpec[]) {
|
|
2567
|
+
self.columnTracks.replace(tracks)
|
|
1906
2568
|
},
|
|
1907
2569
|
/**
|
|
1908
|
-
* #
|
|
1909
|
-
*
|
|
2570
|
+
* #action
|
|
2571
|
+
* replace the alignment<->structure correspondence (see docs/layers.md)
|
|
1910
2572
|
*/
|
|
1911
|
-
|
|
1912
|
-
|
|
2573
|
+
setResidueMappings(mappings: ResidueMapping[]) {
|
|
2574
|
+
self.residueMappings.replace(mappings)
|
|
1913
2575
|
},
|
|
1914
2576
|
/**
|
|
1915
|
-
*
|
|
1916
|
-
* the filter dialog and the palettes key off
|
|
2577
|
+
* #action
|
|
1917
2578
|
*/
|
|
1918
|
-
|
|
1919
|
-
//
|
|
1920
|
-
//
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
types.set(annot.accession, annot)
|
|
1926
|
-
}
|
|
1927
|
-
}
|
|
1928
|
-
return types
|
|
1929
|
-
},
|
|
1930
|
-
get filteredAnnotations() {
|
|
1931
|
-
return self.annotations.filter(r =>
|
|
1932
|
-
self.featureFilters.get(r.accession),
|
|
2579
|
+
toggleTrack(id: string) {
|
|
2580
|
+
// the stored value is "is off", so the current shown state is exactly
|
|
2581
|
+
// what the flipped entry should hold
|
|
2582
|
+
const defaultOff = self.MSA?.tracks.find(t => t.id === id)?.defaultOff
|
|
2583
|
+
self.turnedOffTracks.set(
|
|
2584
|
+
id,
|
|
2585
|
+
!trackIsOff(self.turnedOffTracks, id, defaultOff),
|
|
1933
2586
|
)
|
|
1934
2587
|
},
|
|
1935
|
-
get annotationsByRow() {
|
|
1936
|
-
return groupBy(this.filteredAnnotations, r => r.id)
|
|
1937
|
-
},
|
|
1938
|
-
}))
|
|
1939
|
-
.views(self => ({
|
|
1940
2588
|
/**
|
|
1941
|
-
* #
|
|
2589
|
+
* #action
|
|
1942
2590
|
*/
|
|
1943
|
-
|
|
1944
|
-
|
|
2591
|
+
setStatus(status?: { msg: string; onCancel?: () => void }) {
|
|
2592
|
+
self.status = status
|
|
1945
2593
|
},
|
|
1946
2594
|
}))
|
|
1947
2595
|
.views(self => ({
|
|
@@ -2023,7 +2671,7 @@ function stateModelFactory() {
|
|
|
2023
2671
|
*/
|
|
2024
2672
|
get visibleDomainTypes() {
|
|
2025
2673
|
return this.categoricalDomainTypes
|
|
2026
|
-
.filter(d => self.
|
|
2674
|
+
.filter(d => !self.turnedOffFeatures.get(d.accession))
|
|
2027
2675
|
.toSorted((a, b) => a.start - b.start)
|
|
2028
2676
|
},
|
|
2029
2677
|
|
|
@@ -2053,15 +2701,14 @@ function stateModelFactory() {
|
|
|
2053
2701
|
// gap run -- and a residue whose column is itself hidden
|
|
2054
2702
|
// collapses onto the neighbouring boundary instead of dropping
|
|
2055
2703
|
// the band. A band whose every column is hidden spans nothing
|
|
2056
|
-
// and is left out.
|
|
2057
|
-
const
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
)
|
|
2704
|
+
// and is left out, as is one naming a row the alignment lacks.
|
|
2705
|
+
const start = self.seqPosToGlobalCol(name, annotation.start - 1)
|
|
2706
|
+
const end = self.seqPosToGlobalCol(name, annotation.end - 1)
|
|
2707
|
+
if (start === undefined || end === undefined) {
|
|
2708
|
+
return undefined
|
|
2709
|
+
}
|
|
2710
|
+
const startCol = visibleColsBefore(blanks, start)
|
|
2711
|
+
const endCol = visibleColsBefore(blanks, end + 1)
|
|
2065
2712
|
return endCol > startCol
|
|
2066
2713
|
? { annotation, startCol, endCol }
|
|
2067
2714
|
: undefined
|
|
@@ -2099,14 +2746,19 @@ function stateModelFactory() {
|
|
|
2099
2746
|
* domain annotations under the mouse, hit-tested against the exact visible
|
|
2100
2747
|
* column span each box is drawn at (so it matches the overlay across gaps)
|
|
2101
2748
|
*/
|
|
2102
|
-
get mouseOverDomains() {
|
|
2749
|
+
get mouseOverDomains(): Annotation[] {
|
|
2103
2750
|
const { mouseCol } = self
|
|
2104
2751
|
const name = self.mouseOverRowName
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2752
|
+
if (name === undefined || mouseCol === undefined) {
|
|
2753
|
+
return noDomains
|
|
2754
|
+
}
|
|
2755
|
+
const hits = (this.domainBands.get(name) ?? [])
|
|
2756
|
+
.filter(b => mouseCol >= b.startCol && mouseCol < b.endCol)
|
|
2757
|
+
.map(b => b.annotation)
|
|
2758
|
+
// the shared empty array, so moving the mouse across an alignment with
|
|
2759
|
+
// no annotations does not hand every canvas block a new value to
|
|
2760
|
+
// re-render on
|
|
2761
|
+
return hits.length > 0 ? hits : noDomains
|
|
2110
2762
|
},
|
|
2111
2763
|
|
|
2112
2764
|
/**
|
|
@@ -2166,41 +2818,46 @@ function stateModelFactory() {
|
|
|
2166
2818
|
* is dropped. Row names that match no row are ignored.
|
|
2167
2819
|
*/
|
|
2168
2820
|
get resolvedHighlights(): ResolvedHighlight[] {
|
|
2169
|
-
const { blanks, rowNamesSet,
|
|
2821
|
+
const { blanks, rowNamesSet, transientHighlights } = self
|
|
2170
2822
|
const toVisible = (globalCol: number) => {
|
|
2171
2823
|
const visible = self.globalColToVisibleCol(globalCol)
|
|
2172
2824
|
return visible ?? visibleColsBefore(blanks, globalCol)
|
|
2173
2825
|
}
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2826
|
+
// the document's own highlights first, then what each owner is showing
|
|
2827
|
+
// right now, so a hover draws over a persisted band rather than under it
|
|
2828
|
+
const all = [
|
|
2829
|
+
...self.highlights,
|
|
2830
|
+
...Object.values(transientHighlights).flat(),
|
|
2831
|
+
]
|
|
2832
|
+
return all.flatMap(({ row, rows, start, end, label, color }) => {
|
|
2833
|
+
const base = { label, color }
|
|
2834
|
+
if (rows) {
|
|
2835
|
+
const rowIndices = rows
|
|
2836
|
+
.map(name => rowNamesSet.get(name))
|
|
2837
|
+
.filter(notEmpty)
|
|
2838
|
+
return rowIndices.length ? [{ ...base, rowIndices }] : []
|
|
2839
|
+
}
|
|
2840
|
+
if (start === undefined || end === undefined) {
|
|
2841
|
+
return []
|
|
2842
|
+
}
|
|
2843
|
+
let startGlobal = start - 1
|
|
2844
|
+
let endGlobal = end - 1
|
|
2845
|
+
if (row !== undefined) {
|
|
2846
|
+
const rowStart = self.seqPosToGlobalCol(row, start - 1)
|
|
2847
|
+
const rowEnd = self.seqPosToGlobalCol(row, end - 1)
|
|
2848
|
+
if (rowStart === undefined || rowEnd === undefined) {
|
|
2184
2849
|
return []
|
|
2185
2850
|
}
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
const endVisible = self.globalColToVisibleCol(endGlobal)
|
|
2197
|
-
const endCol =
|
|
2198
|
-
endVisible ?? visibleColsBefore(blanks, endGlobal) - 1
|
|
2199
|
-
return startCol <= endCol
|
|
2200
|
-
? [{ ...base, startCol, endCol, rowIndices: [] }]
|
|
2201
|
-
: []
|
|
2202
|
-
},
|
|
2203
|
-
)
|
|
2851
|
+
startGlobal = rowStart
|
|
2852
|
+
endGlobal = rowEnd
|
|
2853
|
+
}
|
|
2854
|
+
const startCol = toVisible(startGlobal)
|
|
2855
|
+
const endVisible = self.globalColToVisibleCol(endGlobal)
|
|
2856
|
+
const endCol = endVisible ?? visibleColsBefore(blanks, endGlobal) - 1
|
|
2857
|
+
return startCol <= endCol
|
|
2858
|
+
? [{ ...base, startCol, endCol, rowIndices: [] }]
|
|
2859
|
+
: []
|
|
2860
|
+
})
|
|
2204
2861
|
},
|
|
2205
2862
|
|
|
2206
2863
|
/**
|
|
@@ -2264,12 +2921,24 @@ function stateModelFactory() {
|
|
|
2264
2921
|
setConservationTrackHeight(arg: number) {
|
|
2265
2922
|
self.conservationTrackHeight = arg
|
|
2266
2923
|
},
|
|
2924
|
+
/**
|
|
2925
|
+
* #action
|
|
2926
|
+
*/
|
|
2927
|
+
setColumnTrackHeight(id: string, height: number) {
|
|
2928
|
+
self.columnTrackHeights = { ...self.columnTrackHeights, [id]: height }
|
|
2929
|
+
},
|
|
2267
2930
|
/**
|
|
2268
2931
|
* #action
|
|
2269
2932
|
*/
|
|
2270
2933
|
setSequenceLogoTrackHeight(arg: number) {
|
|
2271
2934
|
self.sequenceLogoTrackHeight = arg
|
|
2272
2935
|
},
|
|
2936
|
+
/**
|
|
2937
|
+
* #action
|
|
2938
|
+
*/
|
|
2939
|
+
setArcTrackHeight(arg: number) {
|
|
2940
|
+
self.arcTrackHeight = arg
|
|
2941
|
+
},
|
|
2273
2942
|
/**
|
|
2274
2943
|
* #action
|
|
2275
2944
|
* Return to the import form: every property off `preservedOnReset`
|
|
@@ -2278,6 +2947,9 @@ function stateModelFactory() {
|
|
|
2278
2947
|
* reach are cleared by hand.
|
|
2279
2948
|
*/
|
|
2280
2949
|
reset() {
|
|
2950
|
+
self.resetCount++
|
|
2951
|
+
self.clearWarnings()
|
|
2952
|
+
self.setStatus(undefined)
|
|
2281
2953
|
applySnapshot(
|
|
2282
2954
|
self,
|
|
2283
2955
|
Object.fromEntries(
|
|
@@ -2289,6 +2961,7 @@ function stateModelFactory() {
|
|
|
2289
2961
|
self.setError(undefined)
|
|
2290
2962
|
self.setAnnotations([])
|
|
2291
2963
|
self.setHighlightedColumns(undefined)
|
|
2964
|
+
self.transientHighlights = {}
|
|
2292
2965
|
self.setMousePos(undefined, undefined)
|
|
2293
2966
|
self.setMouseClickPos(undefined, undefined)
|
|
2294
2967
|
self.setHoveredTreeNode(undefined)
|
|
@@ -2302,21 +2975,35 @@ function stateModelFactory() {
|
|
|
2302
2975
|
const blob = new Blob([html], { type: 'image/svg+xml' })
|
|
2303
2976
|
saveAs(blob, exportFileName(self.msaFilehandle, 'svg'))
|
|
2304
2977
|
},
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2978
|
+
/**
|
|
2979
|
+
* #action
|
|
2980
|
+
* draw this annotation type, or stop drawing it. Only the "stop" is
|
|
2981
|
+
* recorded -- see `turnedOffFeatures`
|
|
2982
|
+
*/
|
|
2983
|
+
setFilter(accession: string, shown: boolean) {
|
|
2984
|
+
if (shown) {
|
|
2985
|
+
self.turnedOffFeatures.delete(accession)
|
|
2986
|
+
} else {
|
|
2987
|
+
self.turnedOffFeatures.set(accession, true)
|
|
2308
2988
|
}
|
|
2309
2989
|
},
|
|
2310
|
-
setFilter(arg: string, flag: boolean) {
|
|
2311
|
-
self.featureFilters.set(arg, flag)
|
|
2312
|
-
},
|
|
2313
2990
|
|
|
2314
2991
|
/**
|
|
2315
2992
|
* #action
|
|
2316
2993
|
*/
|
|
2317
2994
|
fit() {
|
|
2318
|
-
|
|
2319
|
-
|
|
2995
|
+
// Each direction's viewport depends on the other's result: fitting the
|
|
2996
|
+
// rows while the columns still overflow measures against a height the
|
|
2997
|
+
// minimap is taking, and fitting the columns while the rows still
|
|
2998
|
+
// overflow measures against a width the vertical scrollbar is taking.
|
|
2999
|
+
// A second pass measures against the geometry the first pass produced,
|
|
3000
|
+
// which is the one the reader ends up looking at.
|
|
3001
|
+
transaction(() => {
|
|
3002
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
3003
|
+
this.fitHorizontally()
|
|
3004
|
+
this.fitVertically()
|
|
3005
|
+
}
|
|
3006
|
+
})
|
|
2320
3007
|
},
|
|
2321
3008
|
/**
|
|
2322
3009
|
* #action
|
|
@@ -2356,15 +3043,6 @@ function stateModelFactory() {
|
|
|
2356
3043
|
self.setHighlightedColumns(self.highlightColumns)
|
|
2357
3044
|
}
|
|
2358
3045
|
|
|
2359
|
-
addDisposer(
|
|
2360
|
-
self,
|
|
2361
|
-
autorun(() => {
|
|
2362
|
-
for (const key of self.annotationTypes.keys()) {
|
|
2363
|
-
this.initFilter(key)
|
|
2364
|
-
}
|
|
2365
|
-
}),
|
|
2366
|
-
)
|
|
2367
|
-
|
|
2368
3046
|
// track the live device pixel ratio so canvas backing stores re-scale
|
|
2369
3047
|
// when the window moves between monitors or the browser zooms. The
|
|
2370
3048
|
// matchMedia query is pinned to the current ratio, so each change
|
|
@@ -2395,41 +3073,62 @@ function stateModelFactory() {
|
|
|
2395
3073
|
* Every loader carries a generation guard: the filehandle can change
|
|
2396
3074
|
* mid-fetch (a second file picked while the first is still in flight),
|
|
2397
3075
|
* and the slower earlier request must not clobber the data, status, or
|
|
2398
|
-
* loading flag belonging to the newer one.
|
|
3076
|
+
* loading flag belonging to the newer one. A superseded or cleared
|
|
3077
|
+
* request is also aborted, rather than left downloading a file nothing
|
|
3078
|
+
* is waiting for, and it gives back the status line it was writing --
|
|
3079
|
+
* a reset() mid-download used to leave "Downloading file" and a Cancel
|
|
3080
|
+
* button behind on the import form.
|
|
2399
3081
|
*
|
|
2400
3082
|
* `clearFilehandle` serves two purposes for the loaders that pass it.
|
|
2401
3083
|
* A local file has no URL to refetch from, so the handle is dropped
|
|
2402
3084
|
* once its bytes are in the model; and a fetch the user cancels drops
|
|
2403
3085
|
* it too, returning the view to the import form rather than leaving a
|
|
2404
3086
|
* stuck spinner.
|
|
3087
|
+
*
|
|
3088
|
+
* `what` names the layer in a failure message. An optional layer
|
|
3089
|
+
* (annotations, row metadata) that fails to load is a warning: it is
|
|
3090
|
+
* not worth replacing an alignment the reader is looking at with an
|
|
3091
|
+
* error screen over a decorative file.
|
|
2405
3092
|
*/
|
|
2406
3093
|
const loadOnFilehandleChange = ({
|
|
3094
|
+
what,
|
|
2407
3095
|
getFilehandle,
|
|
2408
3096
|
onLoad,
|
|
2409
3097
|
setLoading,
|
|
2410
3098
|
clearFilehandle,
|
|
3099
|
+
optional = false,
|
|
2411
3100
|
}: {
|
|
3101
|
+
what: string
|
|
2412
3102
|
getFilehandle: () => FileLocationType | undefined
|
|
2413
3103
|
onLoad: (text: string) => void
|
|
2414
3104
|
setLoading?: (arg: boolean) => void
|
|
2415
3105
|
clearFilehandle?: () => void
|
|
3106
|
+
optional?: boolean
|
|
2416
3107
|
}) => {
|
|
2417
3108
|
let generation = 0
|
|
3109
|
+
let cancelInFlight: (() => void) | undefined
|
|
3110
|
+
addDisposer(self, () => {
|
|
3111
|
+
cancelInFlight?.()
|
|
3112
|
+
})
|
|
2418
3113
|
addDisposer(
|
|
2419
3114
|
self,
|
|
2420
3115
|
autorun(async () => {
|
|
2421
3116
|
const filehandle = getFilehandle()
|
|
2422
|
-
// a cleared filehandle bumps the generation too, so a reset()
|
|
2423
|
-
// mid-download invalidates the in-flight fetch instead of letting
|
|
2424
|
-
// its onLoad land on the emptied model. That also orphans the
|
|
2425
|
-
// invalidated run's `finally`, so the loading flag is cleared
|
|
2426
|
-
// here on its behalf
|
|
2427
3117
|
const current = ++generation
|
|
3118
|
+
cancelInFlight?.()
|
|
3119
|
+
cancelInFlight = undefined
|
|
2428
3120
|
if (!filehandle) {
|
|
2429
3121
|
setLoading?.(false)
|
|
2430
3122
|
return
|
|
2431
3123
|
}
|
|
2432
|
-
const isCurrent = () => current === generation
|
|
3124
|
+
const isCurrent = () => current === generation && isAlive(self)
|
|
3125
|
+
const controller = new AbortController()
|
|
3126
|
+
cancelInFlight = () => {
|
|
3127
|
+
controller.abort()
|
|
3128
|
+
if (isAlive(self)) {
|
|
3129
|
+
self.setStatus(undefined)
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
2433
3132
|
try {
|
|
2434
3133
|
setLoading?.(true)
|
|
2435
3134
|
self.setError(undefined)
|
|
@@ -2440,6 +3139,7 @@ function stateModelFactory() {
|
|
|
2440
3139
|
self.setStatus(status)
|
|
2441
3140
|
}
|
|
2442
3141
|
},
|
|
3142
|
+
{ controller },
|
|
2443
3143
|
)
|
|
2444
3144
|
if (isCurrent()) {
|
|
2445
3145
|
transaction(() => {
|
|
@@ -2455,11 +3155,16 @@ function stateModelFactory() {
|
|
|
2455
3155
|
clearFilehandle?.()
|
|
2456
3156
|
} else {
|
|
2457
3157
|
console.error(e)
|
|
2458
|
-
|
|
3158
|
+
if (optional) {
|
|
3159
|
+
self.addWarning(`The ${what} did not load: ${e}`)
|
|
3160
|
+
} else {
|
|
3161
|
+
self.setError(e)
|
|
3162
|
+
}
|
|
2459
3163
|
}
|
|
2460
3164
|
}
|
|
2461
3165
|
} finally {
|
|
2462
3166
|
if (isCurrent()) {
|
|
3167
|
+
cancelInFlight = undefined
|
|
2463
3168
|
setLoading?.(false)
|
|
2464
3169
|
}
|
|
2465
3170
|
}
|
|
@@ -2468,6 +3173,7 @@ function stateModelFactory() {
|
|
|
2468
3173
|
}
|
|
2469
3174
|
|
|
2470
3175
|
loadOnFilehandleChange({
|
|
3176
|
+
what: 'tree',
|
|
2471
3177
|
getFilehandle: () => self.treeFilehandle,
|
|
2472
3178
|
onLoad: text => {
|
|
2473
3179
|
self.setTree(text)
|
|
@@ -2483,13 +3189,20 @@ function stateModelFactory() {
|
|
|
2483
3189
|
// treeMetadata is decorative and has no import-form step of its own, so
|
|
2484
3190
|
// it keeps no loading flag and nothing to return to on cancel
|
|
2485
3191
|
loadOnFilehandleChange({
|
|
3192
|
+
what: 'row metadata',
|
|
3193
|
+
optional: true,
|
|
2486
3194
|
getFilehandle: () => self.treeMetadataFilehandle,
|
|
2487
3195
|
onLoad: text => {
|
|
2488
3196
|
self.setTreeMetadata(text)
|
|
2489
3197
|
},
|
|
2490
3198
|
})
|
|
2491
3199
|
|
|
2492
|
-
// autorun parses inline gff text from data.gff
|
|
3200
|
+
// autorun parses inline gff text from data.gff. Text that goes away
|
|
3201
|
+
// takes its annotations with it -- setData with a new alignment and no
|
|
3202
|
+
// gff used to leave the previous file's annotations drawn over it --
|
|
3203
|
+
// while annotations a host set directly are left alone, which is why
|
|
3204
|
+
// this tracks what it applied instead of reading the current list
|
|
3205
|
+
let appliedGFF = false
|
|
2493
3206
|
addDisposer(
|
|
2494
3207
|
self,
|
|
2495
3208
|
autorun(() => {
|
|
@@ -2497,19 +3210,29 @@ function stateModelFactory() {
|
|
|
2497
3210
|
if (gffText) {
|
|
2498
3211
|
try {
|
|
2499
3212
|
self.applyGFFText(gffText)
|
|
3213
|
+
appliedGFF = true
|
|
2500
3214
|
} catch (e) {
|
|
3215
|
+
// a malformed overlay is not worth replacing the alignment
|
|
3216
|
+
// with an error screen
|
|
2501
3217
|
console.error(e)
|
|
2502
|
-
self.
|
|
3218
|
+
self.addWarning(`The annotations did not parse: ${e}`)
|
|
2503
3219
|
}
|
|
3220
|
+
} else if (appliedGFF) {
|
|
3221
|
+
appliedGFF = false
|
|
3222
|
+
self.setAnnotations([])
|
|
2504
3223
|
}
|
|
2505
3224
|
}),
|
|
2506
3225
|
)
|
|
2507
3226
|
|
|
2508
|
-
// gffFilehandle carries overlay annotations
|
|
3227
|
+
// gffFilehandle carries overlay annotations. It loads into data.gff and
|
|
3228
|
+
// the autorun above parses it, so there is one parse path whether the
|
|
3229
|
+
// text arrived from a file or from a snapshot
|
|
2509
3230
|
loadOnFilehandleChange({
|
|
3231
|
+
what: 'annotations',
|
|
3232
|
+
optional: true,
|
|
2510
3233
|
getFilehandle: () => self.gffFilehandle,
|
|
2511
3234
|
onLoad: text => {
|
|
2512
|
-
self.
|
|
3235
|
+
self.setGFF(text)
|
|
2513
3236
|
},
|
|
2514
3237
|
clearFilehandle: () => {
|
|
2515
3238
|
self.setGFFFilehandle(undefined)
|
|
@@ -2517,6 +3240,7 @@ function stateModelFactory() {
|
|
|
2517
3240
|
})
|
|
2518
3241
|
|
|
2519
3242
|
loadOnFilehandleChange({
|
|
3243
|
+
what: 'alignment',
|
|
2520
3244
|
getFilehandle: () => self.msaFilehandle,
|
|
2521
3245
|
onLoad: text => {
|
|
2522
3246
|
self.setMSA(text)
|
|
@@ -2580,15 +3304,27 @@ function stateModelFactory() {
|
|
|
2580
3304
|
}),
|
|
2581
3305
|
)
|
|
2582
3306
|
|
|
2583
|
-
//
|
|
3307
|
+
// treeWidth trails the tree area, less whatever the labels take out of
|
|
3308
|
+
// it. A width that arrived with the snapshot is left alone until the
|
|
3309
|
+
// tree area itself moves: a host that opens a view with a narrow tree
|
|
3310
|
+
// beside wide labels -- jbrowse-plugin-msaview does, at treeWidth 100
|
|
3311
|
+
// in a 200px area -- had its value overwritten on the first frame, and
|
|
3312
|
+
// a restored session came back re-derived rather than as it was left.
|
|
3313
|
+
// A getter cannot do this: labelsWidth is measured off the leaves,
|
|
3314
|
+
// which are laid out against treeWidth.
|
|
3315
|
+
let pinnedAreaWidth =
|
|
3316
|
+
self.treeWidth === defaultTreeWidth ? undefined : self.treeAreaWidth
|
|
2584
3317
|
addDisposer(
|
|
2585
3318
|
self,
|
|
2586
3319
|
autorun(() => {
|
|
3320
|
+
const areaWidth = self.treeAreaWidth
|
|
3321
|
+
const labelsWidth = self.labelsWidth
|
|
3322
|
+
if (pinnedAreaWidth === areaWidth) {
|
|
3323
|
+
return
|
|
3324
|
+
}
|
|
3325
|
+
pinnedAreaWidth = undefined
|
|
2587
3326
|
self.setTreeWidth(
|
|
2588
|
-
Math.max(
|
|
2589
|
-
50,
|
|
2590
|
-
self.treeAreaWidth - self.labelsWidth - 10 - self.marginLeft,
|
|
2591
|
-
),
|
|
3327
|
+
Math.max(50, areaWidth - labelsWidth - 10 - self.marginLeft),
|
|
2592
3328
|
)
|
|
2593
3329
|
}),
|
|
2594
3330
|
)
|