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.
Files changed (125) hide show
  1. package/bundle/index.js +499 -295
  2. package/bundle/index.js.map +4 -4
  3. package/dist/blockFasta.d.ts +6 -0
  4. package/dist/blockFasta.js +12 -0
  5. package/dist/blockFasta.js.map +1 -0
  6. package/dist/colorSchemes.d.ts +7 -0
  7. package/dist/colorSchemes.js +15 -0
  8. package/dist/colorSchemes.js.map +1 -1
  9. package/dist/components/MSAView.js +15 -9
  10. package/dist/components/MSAView.js.map +1 -1
  11. package/dist/components/MSAViewer.d.ts +28 -7
  12. package/dist/components/MSAViewer.js +61 -22
  13. package/dist/components/MSAViewer.js.map +1 -1
  14. package/dist/components/MetadataTable.d.ts +5 -0
  15. package/dist/components/MetadataTable.js +29 -0
  16. package/dist/components/MetadataTable.js.map +1 -0
  17. package/dist/components/Track.js +11 -2
  18. package/dist/components/Track.js.map +1 -1
  19. package/dist/components/dialogs/MetadataDialog.js +5 -6
  20. package/dist/components/dialogs/MetadataDialog.js.map +1 -1
  21. package/dist/components/header/ColorSchemeMenu.d.ts +6 -0
  22. package/dist/components/header/ColorSchemeMenu.js +23 -5
  23. package/dist/components/header/ColorSchemeMenu.js.map +1 -1
  24. package/dist/components/header/GoToBox.d.ts +338 -0
  25. package/dist/components/header/GoToBox.js +31 -0
  26. package/dist/components/header/GoToBox.js.map +1 -0
  27. package/dist/components/header/Header.js +4 -2
  28. package/dist/components/header/Header.js.map +1 -1
  29. package/dist/components/header/SelectionMenu.d.ts +12 -0
  30. package/dist/components/header/SelectionMenu.js +72 -0
  31. package/dist/components/header/SelectionMenu.js.map +1 -0
  32. package/dist/components/header/goTo.d.ts +20 -0
  33. package/dist/components/header/goTo.js +69 -0
  34. package/dist/components/header/goTo.js.map +1 -0
  35. package/dist/components/msa/MSACanvas.js +13 -2
  36. package/dist/components/msa/MSACanvas.js.map +1 -1
  37. package/dist/components/msa/MSACanvasBlock.js +5 -1
  38. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  39. package/dist/components/msa/msaKeys.js +6 -0
  40. package/dist/components/msa/msaKeys.js.map +1 -1
  41. package/dist/components/msa/msaRaster.js +1 -0
  42. package/dist/components/msa/msaRaster.js.map +1 -1
  43. package/dist/components/msa/renderHighlights.d.ts +14 -3
  44. package/dist/components/msa/renderHighlights.js +29 -4
  45. package/dist/components/msa/renderHighlights.js.map +1 -1
  46. package/dist/components/msa/tileColor.js +9 -8
  47. package/dist/components/msa/tileColor.js.map +1 -1
  48. package/dist/components/msa/useMsaBlockMouse.d.ts +1 -1
  49. package/dist/components/msa/useMsaBlockMouse.js +18 -11
  50. package/dist/components/msa/useMsaBlockMouse.js.map +1 -1
  51. package/dist/components/overlayColors.d.ts +2 -0
  52. package/dist/components/overlayColors.js +3 -0
  53. package/dist/components/overlayColors.js.map +1 -1
  54. package/dist/components/selectionDrag.d.ts +17 -0
  55. package/dist/components/selectionDrag.js +30 -0
  56. package/dist/components/selectionDrag.js.map +1 -0
  57. package/dist/components/tracks/useTrackHover.d.ts +3 -1
  58. package/dist/components/tracks/useTrackHover.js +26 -6
  59. package/dist/components/tracks/useTrackHover.js.map +1 -1
  60. package/dist/components/tree/dialogs/TreeNodeInfoDialog.js +9 -8
  61. package/dist/components/tree/dialogs/TreeNodeInfoDialog.js.map +1 -1
  62. package/dist/constants.d.ts +1 -1
  63. package/dist/constants.js +6 -6
  64. package/dist/constants.js.map +1 -1
  65. package/dist/element.d.ts +7 -3
  66. package/dist/element.js +32 -4
  67. package/dist/element.js.map +1 -1
  68. package/dist/featuresLayer.d.ts +8 -0
  69. package/dist/featuresLayer.js +42 -0
  70. package/dist/featuresLayer.js.map +1 -0
  71. package/dist/index.d.ts +1 -1
  72. package/dist/model/DataModel.d.ts +1 -1
  73. package/dist/model/DataModel.js +3 -3
  74. package/dist/model/DataModel.js.map +1 -1
  75. package/dist/model/msaModel.d.ts +14 -1
  76. package/dist/model/msaModel.js +17 -1
  77. package/dist/model/msaModel.js.map +1 -1
  78. package/dist/model.d.ts +242 -187
  79. package/dist/model.js +264 -45
  80. package/dist/model.js.map +1 -1
  81. package/dist/svgTestUtil.d.ts +284 -336
  82. package/dist/types.d.ts +50 -0
  83. package/dist/version.d.ts +1 -1
  84. package/dist/version.js +1 -1
  85. package/package.json +6 -6
  86. package/src/blockFasta.ts +18 -0
  87. package/src/colorSchemes.ts +20 -0
  88. package/src/components/MSAView.tsx +16 -9
  89. package/src/components/MSAViewer.tsx +95 -26
  90. package/src/components/MetadataTable.tsx +55 -0
  91. package/src/components/Track.tsx +14 -1
  92. package/src/components/dialogs/MetadataDialog.tsx +9 -8
  93. package/src/components/header/ColorSchemeMenu.tsx +31 -12
  94. package/src/components/header/GoToBox.tsx +64 -0
  95. package/src/components/header/Header.tsx +6 -2
  96. package/src/components/header/SelectionMenu.tsx +117 -0
  97. package/src/components/header/goTo.ts +75 -0
  98. package/src/components/msa/MSACanvas.tsx +12 -2
  99. package/src/components/msa/MSACanvasBlock.tsx +5 -1
  100. package/src/components/msa/msaKeys.ts +6 -0
  101. package/src/components/msa/msaRaster.ts +1 -0
  102. package/src/components/msa/renderHighlights.ts +46 -4
  103. package/src/components/msa/tileColor.ts +9 -8
  104. package/src/components/msa/useMsaBlockMouse.ts +24 -27
  105. package/src/components/overlayColors.ts +4 -0
  106. package/src/components/selectionDrag.ts +42 -0
  107. package/src/components/tracks/useTrackHover.ts +28 -6
  108. package/src/components/tree/dialogs/TreeNodeInfoDialog.tsx +14 -16
  109. package/src/constants.ts +6 -6
  110. package/src/element.ts +45 -5
  111. package/src/featuresLayer.ts +48 -0
  112. package/src/index.ts +5 -0
  113. package/src/model/DataModel.ts +3 -3
  114. package/src/model/msaModel.ts +19 -1
  115. package/src/model.ts +313 -55
  116. package/src/types.ts +58 -0
  117. package/src/version.ts +1 -1
  118. package/dist/components/header/RowSearch.d.ts +0 -6
  119. package/dist/components/header/RowSearch.js +0 -15
  120. package/dist/components/header/RowSearch.js.map +0 -1
  121. package/dist/components/header/revealRow.d.ts +0 -3
  122. package/dist/components/header/revealRow.js +0 -15
  123. package/dist/components/header/revealRow.js.map +0 -1
  124. package/src/components/header/RowSearch.tsx +0 -34
  125. 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 RowSearch from './RowSearch.tsx'
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
- <RowSearch model={model} />
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={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={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)
@@ -33,6 +33,12 @@ export function onMsaKey(
33
33
  case 'End':
34
34
  model.setScrollX(model.maxScrollX)
35
35
  return true
36
+ case 'Escape':
37
+ if (model.selection) {
38
+ model.clearSelection()
39
+ return true
40
+ }
41
+ return false
36
42
  default:
37
43
  return false
38
44
  }
@@ -297,6 +297,7 @@ function rasterKeys(model: MsaViewModel, theme: Theme) {
297
297
  model.columns,
298
298
  model.leaves,
299
299
  model.colorSchemeName,
300
+ model.customColorScheme,
300
301
  tileColorTable(model),
301
302
  model.relativeTo,
302
303
  theme.palette.action.hover,
@@ -1,5 +1,9 @@
1
1
  import { setFontSize } from '../../setFontSize.ts'
2
- import { referenceColor } from '../overlayColors.ts'
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, and the `highlights` layer. The
178
- * live overlay draws these under its hover and click bands; the SVG export
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 { colorSchemeName } = model
8
- if (colorSchemeName === 'clustalx_protein_dynamic') {
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 (colorSchemeName === 'percent_identity_dynamic') {
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 column table the active scheme colors from, or undefined for the static
24
- // schemes. A cache of rendered colors keys on its identity plus the scheme name.
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 { colorSchemeName } = model
27
- if (colorSchemeName === 'clustalx_protein_dynamic') {
27
+ const { dynamicColorSchemeName } = model
28
+ if (dynamicColorSchemeName === 'clustalx_protein_dynamic') {
28
29
  return model.colClustalX
29
30
  }
30
- if (colorSchemeName === 'percent_identity_dynamic') {
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
- const { colWidth, rowHeight } = model
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
- function onMouseDown(event: React.MouseEvent) {
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 { colWidth, scrollX, numColumns } = model
27
- const { left } = el.getBoundingClientRect()
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 { anchor: tooltip ? anchor : undefined, onMouseMove, onMouseLeave }
58
+ return {
59
+ anchor: tooltip ? anchor : undefined,
60
+ onMouseMove,
61
+ onMouseDown,
62
+ onMouseLeave,
63
+ }
42
64
  }