react-msaview 8.3.0 → 8.4.1
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/bundle/index.js +499 -295
- package/bundle/index.js.map +4 -4
- package/dist/blockFasta.d.ts +6 -0
- package/dist/blockFasta.js +12 -0
- package/dist/blockFasta.js.map +1 -0
- package/dist/colorSchemes.d.ts +7 -0
- package/dist/colorSchemes.js +15 -0
- package/dist/colorSchemes.js.map +1 -1
- package/dist/components/MSAView.js +15 -9
- package/dist/components/MSAView.js.map +1 -1
- package/dist/components/MSAViewer.d.ts +28 -7
- package/dist/components/MSAViewer.js +61 -22
- package/dist/components/MSAViewer.js.map +1 -1
- package/dist/components/MetadataTable.d.ts +5 -0
- package/dist/components/MetadataTable.js +29 -0
- package/dist/components/MetadataTable.js.map +1 -0
- package/dist/components/Track.js +11 -2
- package/dist/components/Track.js.map +1 -1
- package/dist/components/dialogs/MetadataDialog.js +5 -6
- package/dist/components/dialogs/MetadataDialog.js.map +1 -1
- package/dist/components/header/ColorSchemeMenu.d.ts +6 -0
- package/dist/components/header/ColorSchemeMenu.js +23 -5
- package/dist/components/header/ColorSchemeMenu.js.map +1 -1
- package/dist/components/header/GoToBox.d.ts +338 -0
- package/dist/components/header/GoToBox.js +31 -0
- package/dist/components/header/GoToBox.js.map +1 -0
- package/dist/components/header/Header.js +4 -2
- package/dist/components/header/Header.js.map +1 -1
- package/dist/components/header/SelectionMenu.d.ts +12 -0
- package/dist/components/header/SelectionMenu.js +72 -0
- package/dist/components/header/SelectionMenu.js.map +1 -0
- package/dist/components/header/goTo.d.ts +20 -0
- package/dist/components/header/goTo.js +69 -0
- package/dist/components/header/goTo.js.map +1 -0
- package/dist/components/msa/MSACanvas.js +13 -2
- package/dist/components/msa/MSACanvas.js.map +1 -1
- package/dist/components/msa/MSACanvasBlock.js +5 -1
- package/dist/components/msa/MSACanvasBlock.js.map +1 -1
- package/dist/components/msa/msaKeys.js +6 -0
- package/dist/components/msa/msaKeys.js.map +1 -1
- package/dist/components/msa/msaRaster.js +1 -0
- package/dist/components/msa/msaRaster.js.map +1 -1
- package/dist/components/msa/renderHighlights.d.ts +14 -3
- package/dist/components/msa/renderHighlights.js +29 -4
- package/dist/components/msa/renderHighlights.js.map +1 -1
- package/dist/components/msa/tileColor.js +9 -8
- package/dist/components/msa/tileColor.js.map +1 -1
- package/dist/components/msa/useMsaBlockMouse.d.ts +1 -1
- package/dist/components/msa/useMsaBlockMouse.js +18 -11
- package/dist/components/msa/useMsaBlockMouse.js.map +1 -1
- package/dist/components/overlayColors.d.ts +2 -0
- package/dist/components/overlayColors.js +3 -0
- package/dist/components/overlayColors.js.map +1 -1
- package/dist/components/selectionDrag.d.ts +17 -0
- package/dist/components/selectionDrag.js +30 -0
- package/dist/components/selectionDrag.js.map +1 -0
- package/dist/components/tracks/useTrackHover.d.ts +3 -1
- package/dist/components/tracks/useTrackHover.js +26 -6
- package/dist/components/tracks/useTrackHover.js.map +1 -1
- package/dist/components/tree/dialogs/TreeNodeInfoDialog.js +9 -8
- package/dist/components/tree/dialogs/TreeNodeInfoDialog.js.map +1 -1
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +6 -6
- package/dist/constants.js.map +1 -1
- package/dist/element.d.ts +7 -3
- package/dist/element.js +32 -4
- package/dist/element.js.map +1 -1
- package/dist/featuresLayer.d.ts +8 -0
- package/dist/featuresLayer.js +42 -0
- package/dist/featuresLayer.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/model/DataModel.d.ts +1 -1
- package/dist/model/DataModel.js +3 -3
- package/dist/model/DataModel.js.map +1 -1
- package/dist/model/msaModel.d.ts +14 -1
- package/dist/model/msaModel.js +17 -1
- package/dist/model/msaModel.js.map +1 -1
- package/dist/model.d.ts +242 -187
- package/dist/model.js +264 -45
- package/dist/model.js.map +1 -1
- package/dist/svgTestUtil.d.ts +284 -336
- package/dist/types.d.ts +50 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -6
- package/src/blockFasta.ts +18 -0
- package/src/colorSchemes.ts +20 -0
- package/src/components/MSAView.tsx +16 -9
- package/src/components/MSAViewer.tsx +95 -26
- package/src/components/MetadataTable.tsx +55 -0
- package/src/components/Track.tsx +14 -1
- package/src/components/dialogs/MetadataDialog.tsx +9 -8
- package/src/components/header/ColorSchemeMenu.tsx +31 -12
- package/src/components/header/GoToBox.tsx +64 -0
- package/src/components/header/Header.tsx +6 -2
- package/src/components/header/SelectionMenu.tsx +117 -0
- package/src/components/header/goTo.ts +75 -0
- package/src/components/msa/MSACanvas.tsx +12 -2
- package/src/components/msa/MSACanvasBlock.tsx +5 -1
- package/src/components/msa/msaKeys.ts +6 -0
- package/src/components/msa/msaRaster.ts +1 -0
- package/src/components/msa/renderHighlights.ts +46 -4
- package/src/components/msa/tileColor.ts +9 -8
- package/src/components/msa/useMsaBlockMouse.ts +24 -27
- package/src/components/overlayColors.ts +4 -0
- package/src/components/selectionDrag.ts +42 -0
- package/src/components/tracks/useTrackHover.ts +28 -6
- package/src/components/tree/dialogs/TreeNodeInfoDialog.tsx +14 -16
- package/src/constants.ts +6 -6
- package/src/element.ts +45 -5
- package/src/featuresLayer.ts +48 -0
- package/src/index.ts +5 -0
- package/src/model/DataModel.ts +3 -3
- package/src/model/msaModel.ts +19 -1
- package/src/model.ts +313 -55
- package/src/types.ts +58 -0
- package/src/version.ts +1 -1
- package/dist/components/header/RowSearch.d.ts +0 -6
- package/dist/components/header/RowSearch.js +0 -15
- package/dist/components/header/RowSearch.js.map +0 -1
- package/dist/components/header/revealRow.d.ts +0 -3
- package/dist/components/header/revealRow.js +0 -15
- package/dist/components/header/revealRow.js.map +0 -1
- package/src/components/header/RowSearch.tsx +0 -34
- package/src/components/header/revealRow.ts +0 -17
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { Autocomplete, TextField } from '@mui/material'
|
|
4
|
+
import { observer } from 'mobx-react'
|
|
5
|
+
|
|
6
|
+
import { findableRowNames, goTo, rowNameSuggestions } from './goTo.ts'
|
|
7
|
+
|
|
8
|
+
import type { MsaViewModel } from '../../model.ts'
|
|
9
|
+
import type { AutocompleteRenderInputParams } from '@mui/material'
|
|
10
|
+
|
|
11
|
+
const label = 'Go to row or column'
|
|
12
|
+
|
|
13
|
+
// MUI 9 hands renderInput `slotProps`; MUI 7, which every released JBrowse host
|
|
14
|
+
// serves, hands it `inputProps` and no `slotProps` at all
|
|
15
|
+
export function labelledInput(params: AutocompleteRenderInputParams) {
|
|
16
|
+
const { slotProps, inputProps } = params as Partial<
|
|
17
|
+
AutocompleteRenderInputParams & { inputProps: object }
|
|
18
|
+
>
|
|
19
|
+
return slotProps
|
|
20
|
+
? {
|
|
21
|
+
slotProps: {
|
|
22
|
+
...slotProps,
|
|
23
|
+
htmlInput: { ...slotProps.htmlInput, 'aria-label': label },
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
: { inputProps: { ...inputProps, 'aria-label': label } }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const GoToBox = observer(function ({ model }: { model: MsaViewModel }) {
|
|
30
|
+
const [missed, setMissed] = useState(false)
|
|
31
|
+
const names = findableRowNames(model)
|
|
32
|
+
return names.length > 1 || model.numColumns > 0 ? (
|
|
33
|
+
<Autocomplete
|
|
34
|
+
freeSolo
|
|
35
|
+
size="small"
|
|
36
|
+
options={names}
|
|
37
|
+
filterOptions={(options, { inputValue }) =>
|
|
38
|
+
rowNameSuggestions(options, inputValue)
|
|
39
|
+
}
|
|
40
|
+
value={null}
|
|
41
|
+
blurOnSelect
|
|
42
|
+
onInputChange={() => {
|
|
43
|
+
setMissed(false)
|
|
44
|
+
}}
|
|
45
|
+
onChange={(_, text) => {
|
|
46
|
+
if (text) {
|
|
47
|
+
setMissed(!goTo(model, text))
|
|
48
|
+
}
|
|
49
|
+
}}
|
|
50
|
+
style={{ width: 180, margin: 'auto 8px' }}
|
|
51
|
+
renderInput={params => (
|
|
52
|
+
<TextField
|
|
53
|
+
{...params}
|
|
54
|
+
error={missed}
|
|
55
|
+
placeholder={label}
|
|
56
|
+
title="A row name, a column number, or name:residue"
|
|
57
|
+
{...labelledInput(params)}
|
|
58
|
+
/>
|
|
59
|
+
)}
|
|
60
|
+
/>
|
|
61
|
+
) : null
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
export default GoToBox
|
|
@@ -9,11 +9,12 @@ import StatusMessage from '../StatusMessage.tsx'
|
|
|
9
9
|
import ColorSchemeMenu from './ColorSchemeMenu.tsx'
|
|
10
10
|
import FileMenu from './FileMenu.tsx'
|
|
11
11
|
import GappynessSlider from './GappynessSlider.tsx'
|
|
12
|
+
import GoToBox from './GoToBox.tsx'
|
|
12
13
|
import HeaderInfoArea from './HeaderInfoArea.tsx'
|
|
13
14
|
import LoadWarnings from './LoadWarnings.tsx'
|
|
14
15
|
import MSASettingsMenu from './MSASettingsMenu.tsx'
|
|
15
16
|
import MultiAlignmentSelector from './MultiAlignmentSelector.tsx'
|
|
16
|
-
import
|
|
17
|
+
import SelectionMenu from './SelectionMenu.tsx'
|
|
17
18
|
import TreeSettingsMenu from './TreeSettingsMenu.tsx'
|
|
18
19
|
import UnshareableDataWarning from './UnshareableDataWarning.tsx'
|
|
19
20
|
import ZoomControls from './ZoomControls.tsx'
|
|
@@ -43,11 +44,14 @@ const Header = observer(function ({ model }: { model: MsaViewModel }) {
|
|
|
43
44
|
<ZoomControls model={model} />
|
|
44
45
|
<ZoomMenu model={model} />
|
|
45
46
|
<GappynessSlider model={model} />
|
|
46
|
-
<
|
|
47
|
+
<GoToBox model={model} />
|
|
47
48
|
<div style={{ paddingLeft: 20, margin: 'auto' }}>
|
|
48
49
|
<MultiAlignmentSelector model={model} />
|
|
49
50
|
</div>
|
|
51
|
+
{/* zero flex-basis, so what appears here on a selection or a hover
|
|
52
|
+
shrinks into the header's spare width and never wraps it */}
|
|
50
53
|
<div style={{ flex: '1 1 0', minWidth: 0, display: 'flex' }}>
|
|
54
|
+
<SelectionMenu model={model} />
|
|
51
55
|
<HeaderInfoArea model={model} />
|
|
52
56
|
</div>
|
|
53
57
|
<LoadWarnings model={model} />
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import ArrowDropDown from '@mui/icons-material/ArrowDropDown'
|
|
4
|
+
import Clear from '@mui/icons-material/Clear'
|
|
5
|
+
import ContentCopy from '@mui/icons-material/ContentCopy'
|
|
6
|
+
import ZoomIn from '@mui/icons-material/ZoomIn'
|
|
7
|
+
import {
|
|
8
|
+
Button,
|
|
9
|
+
ListItemIcon,
|
|
10
|
+
ListItemText,
|
|
11
|
+
Menu,
|
|
12
|
+
MenuItem,
|
|
13
|
+
Tooltip,
|
|
14
|
+
} from '@mui/material'
|
|
15
|
+
import { observer } from 'mobx-react'
|
|
16
|
+
|
|
17
|
+
import copy from '../../vendor/copyToClipboard.ts'
|
|
18
|
+
|
|
19
|
+
import type { MsaViewModel } from '../../model.ts'
|
|
20
|
+
|
|
21
|
+
function count(n: number, noun: string) {
|
|
22
|
+
return `${n.toLocaleString('en-US')} ${noun}${n === 1 ? '' : 's'}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The selected block's size, as a button opening what to do with it. It
|
|
27
|
+
* renders only while there is a selection. Header.tsx puts it in the slot
|
|
28
|
+
* with no width of its own, so the label ellipsizes before it could wrap the
|
|
29
|
+
* header onto a second line.
|
|
30
|
+
*/
|
|
31
|
+
const SelectionMenu = observer(function ({ model }: { model: MsaViewModel }) {
|
|
32
|
+
const [anchorEl, setAnchorEl] = useState<HTMLElement>()
|
|
33
|
+
const { selectionSize, selectionFileSpan } = model
|
|
34
|
+
if (!selectionSize || !selectionFileSpan) {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
const label = `${count(selectionSize.columns, 'column')} × ${count(selectionSize.rows, 'row')}`
|
|
38
|
+
const items = [
|
|
39
|
+
{
|
|
40
|
+
text: 'Copy as FASTA',
|
|
41
|
+
Icon: ContentCopy,
|
|
42
|
+
act: () => {
|
|
43
|
+
copy(model.selectionFasta)
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
text: 'Zoom to selection',
|
|
48
|
+
Icon: ZoomIn,
|
|
49
|
+
act: () => {
|
|
50
|
+
model.zoomToSelection()
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
text: 'Clear selection',
|
|
55
|
+
Icon: Clear,
|
|
56
|
+
act: () => {
|
|
57
|
+
model.clearSelection()
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
]
|
|
61
|
+
return (
|
|
62
|
+
<>
|
|
63
|
+
<Tooltip
|
|
64
|
+
title={`Columns ${selectionFileSpan.start}-${selectionFileSpan.end} selected. Shift-drag the alignment to select, Escape to clear`}
|
|
65
|
+
>
|
|
66
|
+
<Button
|
|
67
|
+
size="small"
|
|
68
|
+
aria-label={`Selection: ${label}`}
|
|
69
|
+
endIcon={<ArrowDropDown />}
|
|
70
|
+
onClick={event => {
|
|
71
|
+
setAnchorEl(event.currentTarget)
|
|
72
|
+
}}
|
|
73
|
+
style={{
|
|
74
|
+
margin: 'auto 0 auto 8px',
|
|
75
|
+
flexShrink: 0,
|
|
76
|
+
maxWidth: '100%',
|
|
77
|
+
minWidth: 0,
|
|
78
|
+
textTransform: 'none',
|
|
79
|
+
whiteSpace: 'nowrap',
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
83
|
+
{label}
|
|
84
|
+
</span>
|
|
85
|
+
</Button>
|
|
86
|
+
</Tooltip>
|
|
87
|
+
{anchorEl ? (
|
|
88
|
+
<Menu
|
|
89
|
+
anchorEl={anchorEl}
|
|
90
|
+
open
|
|
91
|
+
transitionDuration={0}
|
|
92
|
+
onClose={() => {
|
|
93
|
+
setAnchorEl(undefined)
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
{items.map(({ text, Icon, act }) => (
|
|
97
|
+
<MenuItem
|
|
98
|
+
key={text}
|
|
99
|
+
dense
|
|
100
|
+
onClick={() => {
|
|
101
|
+
act()
|
|
102
|
+
setAnchorEl(undefined)
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
<ListItemIcon>
|
|
106
|
+
<Icon fontSize="small" />
|
|
107
|
+
</ListItemIcon>
|
|
108
|
+
<ListItemText>{text}</ListItemText>
|
|
109
|
+
</MenuItem>
|
|
110
|
+
))}
|
|
111
|
+
</Menu>
|
|
112
|
+
) : null}
|
|
113
|
+
</>
|
|
114
|
+
)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
export default SelectionMenu
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { MsaViewModel } from '../../model.ts'
|
|
2
|
+
|
|
3
|
+
export function findableRowNames(model: MsaViewModel) {
|
|
4
|
+
const collapsed = new Set(model.collapsed)
|
|
5
|
+
return model.leaves
|
|
6
|
+
.filter(({ data: { id, name } }) => !(collapsed.has(id) && name === id))
|
|
7
|
+
.map(leaf => leaf.data.name)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// not MUI's createFilterOptions: JBrowse hosts do not re-export it, so
|
|
11
|
+
// jbrowse-plugin-msaview would fail to load
|
|
12
|
+
export function rowNameSuggestions(names: string[], input: string, limit = 50) {
|
|
13
|
+
const query = input.toLowerCase()
|
|
14
|
+
return names
|
|
15
|
+
.filter(name => name.toLowerCase().includes(query))
|
|
16
|
+
.slice(0, limit)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The visible row and column a "Go to" entry names: a row by its name, the
|
|
21
|
+
* column the ruler numbers N, or residue N of a row written `name:N`. A name
|
|
22
|
+
* is tried whole first, so a row called `chr1:100` stays reachable.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveGoTo(model: MsaViewModel, text: string) {
|
|
25
|
+
const query = text.trim()
|
|
26
|
+
const row = model.rowNamesSet.get(query)
|
|
27
|
+
if (row !== undefined) {
|
|
28
|
+
return { row }
|
|
29
|
+
}
|
|
30
|
+
const residue = /^(.+):(\d+)$/.exec(query)
|
|
31
|
+
if (residue) {
|
|
32
|
+
const [, name = '', pos] = residue
|
|
33
|
+
const rowIndex = model.rowNamesSet.get(name)
|
|
34
|
+
const col =
|
|
35
|
+
rowIndex === undefined ? undefined : columnOfResidue(model, name, +pos!)
|
|
36
|
+
return col === undefined ? undefined : { row: rowIndex, col }
|
|
37
|
+
}
|
|
38
|
+
if (/^\d+$/.test(query)) {
|
|
39
|
+
const n = +query
|
|
40
|
+
const { relativeTo, numColumns } = model
|
|
41
|
+
const col = relativeTo
|
|
42
|
+
? columnOfResidue(model, relativeTo, n)
|
|
43
|
+
: n >= 1 && n <= numColumns
|
|
44
|
+
? n - 1
|
|
45
|
+
: undefined
|
|
46
|
+
return col === undefined ? undefined : { col }
|
|
47
|
+
}
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// seqPosToVisibleCol maps a residue past the row's end to the column after
|
|
52
|
+
// it; here that residue names nothing
|
|
53
|
+
function columnOfResidue(model: MsaViewModel, name: string, pos: number) {
|
|
54
|
+
const globalCol = model.seqPosIndex(name)?.[pos - 1]
|
|
55
|
+
return globalCol === undefined
|
|
56
|
+
? undefined
|
|
57
|
+
: model.globalColToVisibleCol(globalCol)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** centers the view on the entry's target and lights it; false if none */
|
|
61
|
+
export function goTo(model: MsaViewModel, text: string) {
|
|
62
|
+
const target = resolveGoTo(model, text)
|
|
63
|
+
if (target) {
|
|
64
|
+
const { row, col } = target
|
|
65
|
+
const { rowHeight, colWidth, msaAreaHeight, msaAreaWidth } = model
|
|
66
|
+
if (row !== undefined) {
|
|
67
|
+
model.setScrollY(-(row + 0.5) * rowHeight + msaAreaHeight / 2)
|
|
68
|
+
}
|
|
69
|
+
if (col !== undefined) {
|
|
70
|
+
model.setScrollX(-(col + 0.5) * colWidth + msaAreaWidth / 2)
|
|
71
|
+
}
|
|
72
|
+
model.setMousePos(col, row)
|
|
73
|
+
}
|
|
74
|
+
return target !== undefined
|
|
75
|
+
}
|
|
@@ -54,13 +54,23 @@ const MSACanvas = observer(function ({ model }: { model: MsaViewModel }) {
|
|
|
54
54
|
// col*colWidth + scrollX, row*rowHeight + scrollY
|
|
55
55
|
data-testid="msa_canvas"
|
|
56
56
|
tabIndex={0}
|
|
57
|
-
aria-label="Alignment. Arrow keys scroll, shift pages, + and - zoom, Home and End jump to the first and last column."
|
|
57
|
+
aria-label="Alignment. Arrow keys scroll, shift pages, + and - zoom, Home and End jump to the first and last column. Shift-drag selects cells, and Escape clears the selection."
|
|
58
58
|
onKeyDown={event => {
|
|
59
59
|
if (onMsaKey(model, event)) {
|
|
60
60
|
event.preventDefault()
|
|
61
61
|
}
|
|
62
62
|
}}
|
|
63
|
-
onMouseDown={
|
|
63
|
+
onMouseDown={event => {
|
|
64
|
+
// a shifted press selects cells (useMsaBlockMouse), so it takes the
|
|
65
|
+
// focus Escape clears from, and leaves out the pan and the page's own
|
|
66
|
+
// text selection
|
|
67
|
+
if (event.shiftKey) {
|
|
68
|
+
event.preventDefault()
|
|
69
|
+
ref.current?.focus({ preventScroll: true })
|
|
70
|
+
} else {
|
|
71
|
+
onMouseDown(event)
|
|
72
|
+
}
|
|
73
|
+
}}
|
|
64
74
|
onMouseUp={onMouseUp}
|
|
65
75
|
style={{
|
|
66
76
|
position: 'relative',
|
|
@@ -71,7 +71,11 @@ const MSACanvasBlock = observer(function ({
|
|
|
71
71
|
onMouseMove(event, ref.current)
|
|
72
72
|
}
|
|
73
73
|
}}
|
|
74
|
-
onMouseDown={
|
|
74
|
+
onMouseDown={event => {
|
|
75
|
+
if (ref.current) {
|
|
76
|
+
onMouseDown(event, ref.current)
|
|
77
|
+
}
|
|
78
|
+
}}
|
|
75
79
|
onClick={event => {
|
|
76
80
|
if (ref.current) {
|
|
77
81
|
onClick(event, ref.current)
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { setFontSize } from '../../setFontSize.ts'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
referenceColor,
|
|
4
|
+
selectionBorder,
|
|
5
|
+
selectionFill,
|
|
6
|
+
} from '../overlayColors.ts'
|
|
3
7
|
|
|
4
8
|
import type { MsaViewModel } from '../../model.ts'
|
|
5
9
|
import type { RenderCtx } from '../renderCtx.ts'
|
|
@@ -172,11 +176,48 @@ export function renderHighlights({
|
|
|
172
176
|
}
|
|
173
177
|
}
|
|
174
178
|
|
|
179
|
+
/**
|
|
180
|
+
* The selected block, a bordered band per run of consecutive selected rows,
|
|
181
|
+
* culled to the rows this block covers
|
|
182
|
+
*/
|
|
183
|
+
export function renderSelection({
|
|
184
|
+
ctx,
|
|
185
|
+
model,
|
|
186
|
+
offsetX,
|
|
187
|
+
offsetY,
|
|
188
|
+
height,
|
|
189
|
+
}: {
|
|
190
|
+
ctx: RenderCtx
|
|
191
|
+
model: MsaViewModel
|
|
192
|
+
offsetX: number
|
|
193
|
+
offsetY: number
|
|
194
|
+
height: number
|
|
195
|
+
}) {
|
|
196
|
+
const { resolvedSelection, colWidth, rowHeight } = model
|
|
197
|
+
if (!resolvedSelection) {
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
const { startCol, endCol, rowRuns } = resolvedSelection
|
|
201
|
+
const x = startCol * colWidth - offsetX
|
|
202
|
+
const w = (endCol - startCol + 1) * colWidth
|
|
203
|
+
ctx.lineWidth = 1
|
|
204
|
+
for (const [first, last] of rowRuns) {
|
|
205
|
+
const y = first * rowHeight - offsetY
|
|
206
|
+
const h = (last - first + 1) * rowHeight
|
|
207
|
+
if (y < height && y + h > 0) {
|
|
208
|
+
ctx.fillStyle = selectionFill
|
|
209
|
+
ctx.fillRect(x, y, w, h)
|
|
210
|
+
ctx.strokeStyle = selectionBorder
|
|
211
|
+
ctx.strokeRect(x, y, w, h)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
175
216
|
/**
|
|
176
217
|
* The overlay parts that come from the snapshot: the reference row's tint, a
|
|
177
|
-
* bordered band per run of highlighted columns,
|
|
178
|
-
* live overlay draws these under its hover and click
|
|
179
|
-
* draws only these.
|
|
218
|
+
* bordered band per run of highlighted columns, the `highlights` layer and the
|
|
219
|
+
* selected block. The live overlay draws these under its hover and click
|
|
220
|
+
* bands; the SVG export draws only these.
|
|
180
221
|
*/
|
|
181
222
|
export function renderPersistentHighlights({
|
|
182
223
|
ctx,
|
|
@@ -214,4 +255,5 @@ export function renderPersistentHighlights({
|
|
|
214
255
|
}
|
|
215
256
|
|
|
216
257
|
renderHighlights({ ctx, model, theme, offsetX, offsetY, width, height })
|
|
258
|
+
renderSelection({ ctx, model, offsetX, offsetY, height })
|
|
217
259
|
}
|
|
@@ -4,12 +4,12 @@ import type { MsaViewModel } from '../../model.ts'
|
|
|
4
4
|
// only the active scheme's column table leaves the other per-column table
|
|
5
5
|
// uncomputed.
|
|
6
6
|
export function tileColorFn(model: MsaViewModel) {
|
|
7
|
-
const {
|
|
8
|
-
if (
|
|
7
|
+
const { dynamicColorSchemeName } = model
|
|
8
|
+
if (dynamicColorSchemeName === 'clustalx_protein_dynamic') {
|
|
9
9
|
const { colClustalX } = model
|
|
10
10
|
return (col: number, letter: string) => colClustalX[col]![letter]
|
|
11
11
|
}
|
|
12
|
-
if (
|
|
12
|
+
if (dynamicColorSchemeName === 'percent_identity_dynamic') {
|
|
13
13
|
const { colConsensus } = model
|
|
14
14
|
return (col: number, letter: string) => {
|
|
15
15
|
const consensus = colConsensus[col]!
|
|
@@ -20,14 +20,15 @@ export function tileColorFn(model: MsaViewModel) {
|
|
|
20
20
|
return (_col: number, letter: string) => colorScheme[letter]
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
// The
|
|
24
|
-
//
|
|
23
|
+
// The table the active scheme colors from: a dynamic scheme's per-column table,
|
|
24
|
+
// or the letter table of a static scheme or a `customColorScheme` map. A cache
|
|
25
|
+
// of rendered colors keys on its identity plus the scheme name.
|
|
25
26
|
export function tileColorTable(model: MsaViewModel) {
|
|
26
|
-
const {
|
|
27
|
-
if (
|
|
27
|
+
const { dynamicColorSchemeName } = model
|
|
28
|
+
if (dynamicColorSchemeName === 'clustalx_protein_dynamic') {
|
|
28
29
|
return model.colClustalX
|
|
29
30
|
}
|
|
30
|
-
if (
|
|
31
|
+
if (dynamicColorSchemeName === 'percent_identity_dynamic') {
|
|
31
32
|
return model.colConsensus
|
|
32
33
|
}
|
|
33
34
|
return model.colorScheme
|
|
@@ -1,34 +1,12 @@
|
|
|
1
1
|
import { useRef } from 'react'
|
|
2
2
|
|
|
3
|
+
import { dragSelection, maxClickTravel } from '../selectionDrag.ts'
|
|
3
4
|
import { useHoverAnchor } from '../useHoverAnchor.ts'
|
|
4
5
|
|
|
5
6
|
import type { MsaViewModel } from '../../model.ts'
|
|
7
|
+
import type { ClientPoint } from '../selectionDrag.ts'
|
|
6
8
|
import type React from 'react'
|
|
7
9
|
|
|
8
|
-
const maxClickTravel = 3
|
|
9
|
-
|
|
10
|
-
function eventToColRow({
|
|
11
|
-
event,
|
|
12
|
-
el,
|
|
13
|
-
offsetX,
|
|
14
|
-
offsetY,
|
|
15
|
-
colWidth,
|
|
16
|
-
rowHeight,
|
|
17
|
-
}: {
|
|
18
|
-
event: React.MouseEvent
|
|
19
|
-
el: HTMLElement
|
|
20
|
-
offsetX: number
|
|
21
|
-
offsetY: number
|
|
22
|
-
colWidth: number
|
|
23
|
-
rowHeight: number
|
|
24
|
-
}) {
|
|
25
|
-
const { left, top } = el.getBoundingClientRect()
|
|
26
|
-
return {
|
|
27
|
-
col: Math.floor((event.clientX - left + offsetX) / colWidth),
|
|
28
|
-
row: Math.floor((event.clientY - top + offsetY) / rowHeight),
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
10
|
/**
|
|
33
11
|
* Translates pointer events over one MSA block into model column/row hover and
|
|
34
12
|
* click state, and reports where a tooltip should be anchored.
|
|
@@ -51,9 +29,23 @@ export function useMsaBlockMouse({
|
|
|
51
29
|
const { anchor, hoverAt, clearAnchor } = useHoverAnchor<true>()
|
|
52
30
|
const downAt = useRef<{ x: number; y: number }>(undefined)
|
|
53
31
|
|
|
32
|
+
// The block's rect places its cells on the page. A drag keeps the mapping
|
|
33
|
+
// it started with, so each point subtracts the scroll since then.
|
|
34
|
+
function cellMapper(el: HTMLElement) {
|
|
35
|
+
const { left, top } = el.getBoundingClientRect()
|
|
36
|
+
const { scrollX, scrollY } = model
|
|
37
|
+
return ({ clientX, clientY }: ClientPoint) => ({
|
|
38
|
+
col: Math.floor(
|
|
39
|
+
(clientX - left + offsetX - (model.scrollX - scrollX)) / model.colWidth,
|
|
40
|
+
),
|
|
41
|
+
row: Math.floor(
|
|
42
|
+
(clientY - top + offsetY - (model.scrollY - scrollY)) / model.rowHeight,
|
|
43
|
+
),
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
54
47
|
function colRow(event: React.MouseEvent, el: HTMLElement) {
|
|
55
|
-
|
|
56
|
-
return eventToColRow({ event, el, offsetX, offsetY, colWidth, rowHeight })
|
|
48
|
+
return cellMapper(el)(event)
|
|
57
49
|
}
|
|
58
50
|
|
|
59
51
|
// a block can extend past the alignment (the last block is a full tile wide,
|
|
@@ -73,8 +65,13 @@ export function useMsaBlockMouse({
|
|
|
73
65
|
hoverAt(event, hasTooltip || undefined)
|
|
74
66
|
}
|
|
75
67
|
|
|
76
|
-
|
|
68
|
+
// shift+drag selects a block; MSACanvas leaves a shifted press out of its
|
|
69
|
+
// pan
|
|
70
|
+
function onMouseDown(event: React.MouseEvent, el: HTMLElement) {
|
|
77
71
|
downAt.current = { x: event.clientX, y: event.clientY }
|
|
72
|
+
if (event.shiftKey && event.button === 0) {
|
|
73
|
+
dragSelection(model, event, cellMapper(el))
|
|
74
|
+
}
|
|
78
75
|
}
|
|
79
76
|
|
|
80
77
|
function onClick(event: React.MouseEvent, el: HTMLElement) {
|
|
@@ -16,3 +16,7 @@ export const multiRowHoverColor = 'rgba(255,165,0,0.15)'
|
|
|
16
16
|
// match or the crosshair appears to change color as it crosses the tracks.
|
|
17
17
|
export const hoverColor = 'rgba(0,0,0,0.15)'
|
|
18
18
|
export const clickColor = 'rgba(128,128,0,0.2)'
|
|
19
|
+
|
|
20
|
+
// the selected block, a blue that reads apart from the orange highlights
|
|
21
|
+
export const selectionFill = 'rgba(25,118,210,0.22)'
|
|
22
|
+
export const selectionBorder = 'rgba(25,118,210,0.95)'
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { isAlive } from '@jbrowse/mobx-state-tree'
|
|
2
|
+
|
|
3
|
+
import type { MsaViewModel } from '../model.ts'
|
|
4
|
+
|
|
5
|
+
// a press that travels further than this, in px, is a drag and not a click
|
|
6
|
+
export const maxClickTravel = 3
|
|
7
|
+
|
|
8
|
+
export interface ClientPoint {
|
|
9
|
+
clientX: number
|
|
10
|
+
clientY: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Follows a press through the window until its release, selecting the block
|
|
15
|
+
* between the cell it started on and the cell under the pointer. The selection
|
|
16
|
+
* starts once the pointer travels past `maxClickTravel`, so a press that stays
|
|
17
|
+
* put reaches the click handlers unchanged. `cellAt` maps a client point to a
|
|
18
|
+
* visible column and row index, or a column alone to select across every row.
|
|
19
|
+
*/
|
|
20
|
+
export function dragSelection(
|
|
21
|
+
model: MsaViewModel,
|
|
22
|
+
start: ClientPoint,
|
|
23
|
+
cellAt: (point: ClientPoint) => { col: number; row?: number },
|
|
24
|
+
) {
|
|
25
|
+
const anchor = cellAt(start)
|
|
26
|
+
const { clientX, clientY } = start
|
|
27
|
+
let dragging = false
|
|
28
|
+
function onMove(event: MouseEvent) {
|
|
29
|
+
dragging ||=
|
|
30
|
+
Math.hypot(event.clientX - clientX, event.clientY - clientY) >
|
|
31
|
+
maxClickTravel
|
|
32
|
+
if (dragging && isAlive(model)) {
|
|
33
|
+
model.selectBlock(anchor, cellAt(event))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function onUp() {
|
|
37
|
+
window.removeEventListener('mousemove', onMove)
|
|
38
|
+
window.removeEventListener('mouseup', onUp)
|
|
39
|
+
}
|
|
40
|
+
window.addEventListener('mousemove', onMove)
|
|
41
|
+
window.addEventListener('mouseup', onUp)
|
|
42
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dragSelection } from '../selectionDrag.ts'
|
|
1
2
|
import { useHoverAnchor } from '../useHoverAnchor.ts'
|
|
2
3
|
|
|
3
4
|
import type { MsaViewModel } from '../../model.ts'
|
|
@@ -6,7 +7,8 @@ import type React from 'react'
|
|
|
6
7
|
/**
|
|
7
8
|
* Translates pointer events over a track's row into the hovered column and a
|
|
8
9
|
* tooltip anchor. The track shares the alignment's columns, so hovering it sets
|
|
9
|
-
* the model's mouse column too and the alignment's hover band follows along.
|
|
10
|
+
* the model's mouse column too and the alignment's hover band follows along. A
|
|
11
|
+
* drag along the row selects the columns it covers across every row.
|
|
10
12
|
*
|
|
11
13
|
* `tooltip` off skips the anchor, so a track whose tooltip is turned off still
|
|
12
14
|
* drives the hover band without re-rendering on every mouse move.
|
|
@@ -22,21 +24,41 @@ export function useTrackHover({
|
|
|
22
24
|
|
|
23
25
|
// the blocks are laid out in column space and scrolled by a transform on
|
|
24
26
|
// their container, so the pointer's column comes off the untransformed row
|
|
27
|
+
function colAt(clientX: number, left: number) {
|
|
28
|
+
return Math.floor((clientX - left - model.scrollX) / model.colWidth)
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
function onMouseMove(event: React.MouseEvent, el: HTMLElement) {
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const col = Math.floor((event.clientX - left - scrollX) / colWidth)
|
|
29
|
-
const hit = col >= 0 && col < numColumns
|
|
32
|
+
const col = colAt(event.clientX, el.getBoundingClientRect().left)
|
|
33
|
+
const hit = col >= 0 && col < model.numColumns
|
|
30
34
|
model.setMousePos(hit ? col : undefined, undefined)
|
|
31
35
|
if (tooltip) {
|
|
32
36
|
hoverAt(event, hit ? col : undefined)
|
|
33
37
|
}
|
|
34
38
|
}
|
|
35
39
|
|
|
40
|
+
// a press on a track's canvas, which leaves out the resize divider under it.
|
|
41
|
+
// The row takes the focus, so Escape reaches onMsaKey from there too
|
|
42
|
+
function onMouseDown(event: React.MouseEvent, el: HTMLElement) {
|
|
43
|
+
if (event.button === 0 && event.target instanceof HTMLCanvasElement) {
|
|
44
|
+
event.preventDefault()
|
|
45
|
+
el.focus({ preventScroll: true })
|
|
46
|
+
const { left } = el.getBoundingClientRect()
|
|
47
|
+
dragSelection(model, event, ({ clientX }) => ({
|
|
48
|
+
col: colAt(clientX, left),
|
|
49
|
+
}))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
36
53
|
function onMouseLeave() {
|
|
37
54
|
clearAnchor()
|
|
38
55
|
model.setMousePos(undefined, undefined)
|
|
39
56
|
}
|
|
40
57
|
|
|
41
|
-
return {
|
|
58
|
+
return {
|
|
59
|
+
anchor: tooltip ? anchor : undefined,
|
|
60
|
+
onMouseMove,
|
|
61
|
+
onMouseDown,
|
|
62
|
+
onMouseLeave,
|
|
63
|
+
}
|
|
42
64
|
}
|