jbrowse-plugin-graphgenomeviewer 6.2.0 → 6.3.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 (78) hide show
  1. package/README.md +4 -3
  2. package/dist/chunks/ChooseWalksDialog-L6QKOT7M.js +2 -0
  3. package/dist/chunks/ChooseWalksDialog-L6QKOT7M.js.map +7 -0
  4. package/dist/chunks/{GbzBaseSyntenyAdapter-TLI5IUU7.js → GbzBaseSyntenyAdapter-KVJKGGQ4.js} +2 -2
  5. package/dist/chunks/{GbzBaseSyntenyAdapter-TLI5IUU7.js.map → GbzBaseSyntenyAdapter-KVJKGGQ4.js.map} +1 -1
  6. package/dist/chunks/{GraphGenomeView-5OPCROMQ.js → GraphGenomeView-VIKLXNQD.js} +2 -2
  7. package/dist/chunks/{GraphGenomeView-5OPCROMQ.js.map → GraphGenomeView-VIKLXNQD.js.map} +1 -1
  8. package/dist/chunks/GraphTrackSettingsDialog-MRMSH2FO.js +4 -0
  9. package/dist/chunks/{GraphTrackSettingsDialog-FGLLUIEE.js.map → GraphTrackSettingsDialog-MRMSH2FO.js.map} +1 -1
  10. package/dist/chunks/HighlightColorDialog-SABW5OMB.js +2 -0
  11. package/dist/chunks/HighlightColorDialog-SABW5OMB.js.map +7 -0
  12. package/dist/chunks/LinearGraphDisplay-AHCKWNFJ.js +2 -0
  13. package/dist/chunks/{LinearGraphDisplay-NBJHGOYX.js.map → LinearGraphDisplay-AHCKWNFJ.js.map} +1 -1
  14. package/dist/chunks/{MinigraphBubbleAdapter-LQUIL3MD.js → MinigraphBubbleAdapter-CAXB5FGR.js} +2 -2
  15. package/dist/chunks/{RgfaTabixAdapter-3BFNY4SB.js → RgfaTabixAdapter-GYRNCGT2.js} +2 -2
  16. package/dist/chunks/{bandage-layout-3AR6BCUD-I6CCWYSR.js → bandage-layout-3AR6BCUD-PYYNJEGI.js} +1 -1
  17. package/dist/chunks/{bandage-layout-3AR6BCUD-I6CCWYSR.js.map → bandage-layout-3AR6BCUD-PYYNJEGI.js.map} +1 -1
  18. package/dist/chunks/{chunk-O5EHYU3Y.js → chunk-2SIR336R.js} +2 -2
  19. package/dist/chunks/chunk-2SL675KP.js +3 -0
  20. package/dist/chunks/{chunk-NGQRKSHY.js.map → chunk-2SL675KP.js.map} +3 -3
  21. package/dist/chunks/chunk-A6GKF63C.js +2 -0
  22. package/dist/chunks/{chunk-ZKLOV6MW.js.map → chunk-A6GKF63C.js.map} +2 -2
  23. package/dist/chunks/chunk-B7GF3TC3.js +2 -0
  24. package/dist/chunks/chunk-B7GF3TC3.js.map +7 -0
  25. package/dist/chunks/chunk-B7KMMGFB.js +2 -0
  26. package/dist/chunks/chunk-B7KMMGFB.js.map +7 -0
  27. package/dist/chunks/{chunk-X24J6VF4.js → chunk-DILFGQIL.js} +1 -1
  28. package/dist/chunks/{chunk-X24J6VF4.js.map → chunk-DILFGQIL.js.map} +1 -1
  29. package/dist/chunks/{chunk-7MWOPEIC.js → chunk-EHV7EUMI.js} +1 -1
  30. package/dist/chunks/{chunk-7MWOPEIC.js.map → chunk-EHV7EUMI.js.map} +1 -1
  31. package/dist/chunks/chunk-GMSOU6VO.js +2 -0
  32. package/dist/chunks/chunk-GMSOU6VO.js.map +7 -0
  33. package/dist/chunks/{chunk-EKMGHBWM.js → chunk-KRXALLQG.js} +1 -1
  34. package/dist/chunks/{chunk-EKMGHBWM.js.map → chunk-KRXALLQG.js.map} +1 -1
  35. package/dist/chunks/{chunk-F7V6ZPKD.js → chunk-MYJYGSOH.js} +1 -1
  36. package/dist/chunks/{chunk-F7V6ZPKD.js.map → chunk-MYJYGSOH.js.map} +1 -1
  37. package/dist/chunks/chunk-WTOQRUK2.js +2 -0
  38. package/dist/chunks/chunk-WTOQRUK2.js.map +7 -0
  39. package/dist/chunks/{chunk-JYZAMHIP.js → chunk-WUGRVNUJ.js} +2 -2
  40. package/dist/chunks/{chunk-JYZAMHIP.js.map → chunk-WUGRVNUJ.js.map} +1 -1
  41. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js +6 -6
  42. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js.map +4 -4
  43. package/package.json +2 -3
  44. package/src/GbzBaseSyntenyAdapter/configSchema.ts +1 -1
  45. package/src/GraphGenomeView/components/ChooseWalksDialog.tsx +6 -7
  46. package/src/GraphGenomeView/components/HighlightColorDialog.tsx +117 -0
  47. package/src/GraphGenomeView/components/TubeMapOverlay.tsx +1 -1
  48. package/src/GraphGenomeView/model.test.ts +107 -31
  49. package/src/GraphGenomeView/pane/fitViews.ts +8 -5
  50. package/src/GraphGenomeView/pane/graphViews.ts +11 -1
  51. package/src/GraphGenomeView/pane/hostViews.ts +1 -1
  52. package/src/GraphGenomeView/pane/keepInView.ts +62 -0
  53. package/src/GraphGenomeView/pane/launchActions.ts +159 -184
  54. package/src/GraphGenomeView/pane/loadActions.ts +3 -0
  55. package/src/GraphGenomeView/pane/paneBase.ts +9 -3
  56. package/src/GraphGenomeView/pane/renderingActions.ts +11 -1
  57. package/src/GraphGenomeView/pane/settingActions.ts +38 -0
  58. package/src/GraphGenomeView/renderPipeline.test.ts +4 -1
  59. package/src/GraphGenomeView/renderer/GraphRenderer.ts +2 -2
  60. package/src/GraphGenomeView/viewModel.ts +1 -1
  61. package/src/LinearGraphDisplay/LinearGraphDisplay.test.ts +12 -5
  62. package/src/LinearGraphDisplay/configSchema.ts +8 -0
  63. package/src/LinearGraphDisplay/model.ts +39 -33
  64. package/src/hoverSync/GraphNodeHighlight.tsx +3 -3
  65. package/src/version.ts +1 -1
  66. package/dist/chunks/ChooseWalksDialog-GVQLSGHX.js +0 -2
  67. package/dist/chunks/ChooseWalksDialog-GVQLSGHX.js.map +0 -7
  68. package/dist/chunks/GraphTrackSettingsDialog-FGLLUIEE.js +0 -4
  69. package/dist/chunks/LinearGraphDisplay-NBJHGOYX.js +0 -2
  70. package/dist/chunks/chunk-LZUNK4MQ.js +0 -2
  71. package/dist/chunks/chunk-LZUNK4MQ.js.map +0 -7
  72. package/dist/chunks/chunk-NGQRKSHY.js +0 -3
  73. package/dist/chunks/chunk-XWGKHQYO.js +0 -2
  74. package/dist/chunks/chunk-XWGKHQYO.js.map +0 -7
  75. package/dist/chunks/chunk-ZKLOV6MW.js +0 -2
  76. /package/dist/chunks/{MinigraphBubbleAdapter-LQUIL3MD.js.map → MinigraphBubbleAdapter-CAXB5FGR.js.map} +0 -0
  77. /package/dist/chunks/{RgfaTabixAdapter-3BFNY4SB.js.map → RgfaTabixAdapter-GYRNCGT2.js.map} +0 -0
  78. /package/dist/chunks/{chunk-O5EHYU3Y.js.map → chunk-2SIR336R.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jbrowse-plugin-graphgenomeviewer",
3
- "version": "6.2.0",
3
+ "version": "6.3.1",
4
4
  "description": "JBrowse 2 graph genome (pangenome) view: rGFA reference-anchored layouts, the Bandage force-directed engine, and haplotype lanes read from a gbz-base database",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -28,7 +28,6 @@
28
28
  "@gmod/bgzf-filehandle": "^6.7.0",
29
29
  "@gmod/gbz-base": "^7.1.0",
30
30
  "@gmod/tabix": "^3.9.1",
31
- "@gmod/tubemap-core": "^0.1.0",
32
31
  "@jbrowse/cigar-utils": "5.0.0-beta.11",
33
32
  "@jbrowse/core": "5.0.0-beta.11",
34
33
  "@jbrowse/display-kit": "5.0.0-beta.11",
@@ -40,7 +39,7 @@
40
39
  "@emotion/styled": "^11.14.1",
41
40
  "@eslint/js": "^10.0.1",
42
41
  "@fal-works/esbuild-plugin-global-externals": "^2.1.2",
43
- "@jbrowse/bandage-core": "^7.5.1",
42
+ "@jbrowse/bandage-core": "^8.0.1",
44
43
  "@jbrowse/cli": "^4.3.0",
45
44
  "@jbrowse/mobx-state-tree": "^6.7.0",
46
45
  "@jbrowse/plugin-canvas": "5.0.0-beta.11",
@@ -115,7 +115,7 @@ const GbzBaseSyntenyAdapter = ConfigurationSchema(
115
115
  /**
116
116
  * #slot
117
117
  * A tab-separated table of per-sample metadata, which the graph view's
118
- * walk rows group by ("Group by…"). Its header row names the columns
118
+ * walk rows group by ("Group by..."). Its header row names the columns
119
119
  * (`population`, `superpopulation`, ...), and its first column is the
120
120
  * walk's sample (`HG00097`) or haplotype (`HG00097#1`). Empty means no
121
121
  * grouping.
@@ -19,7 +19,7 @@ type WalkPicker = Pick<
19
19
  'walkChoices' | 'walkLayers' | 'walkLabel' | 'liftWalks'
20
20
  >
21
21
 
22
- // Which walks to lift, searched by name, for a graph with more walks than a
22
+ // Which haplotypes to highlight, searched by name, for a graph with more walks than a
23
23
  // menu can list
24
24
  const ChooseWalksDialog = observer(function ChooseWalksDialog({
25
25
  model,
@@ -34,7 +34,7 @@ const ChooseWalksDialog = observer(function ChooseWalksDialog({
34
34
  model.walkLayers.map(l => l.walk).filter(name => names.has(name)),
35
35
  )
36
36
  return (
37
- <Dialog open onClose={onClose} title="Choose walks">
37
+ <Dialog open onClose={onClose} title="Choose haplotypes">
38
38
  <DialogContent>
39
39
  <Autocomplete
40
40
  multiple
@@ -56,15 +56,14 @@ const ChooseWalksDialog = observer(function ChooseWalksDialog({
56
56
  <TextField
57
57
  {...params}
58
58
  autoFocus
59
- label="Walks"
59
+ label="Haplotypes"
60
60
  placeholder="type to search"
61
61
  />
62
62
  )}
63
63
  />
64
64
  <Typography variant="caption" color="text.secondary">
65
- {walkChoices.length.toLocaleString()} walks in this cut. The ones
66
- picked are lifted out of the drawing in this order, and the rest
67
- fades.
65
+ {walkChoices.length.toLocaleString()} haplotypes in this cut. The ones
66
+ picked are highlighted in this order, and the rest fades.
68
67
  </Typography>
69
68
  </DialogContent>
70
69
  <DialogActions>
@@ -76,7 +75,7 @@ const ChooseWalksDialog = observer(function ChooseWalksDialog({
76
75
  onClose()
77
76
  }}
78
77
  >
79
- Lift these walks
78
+ Highlight these
80
79
  </Button>
81
80
  </DialogActions>
82
81
  </Dialog>
@@ -0,0 +1,117 @@
1
+ import { WALK_FIELDS, WALK_SCHEMES } from '@jbrowse/bandage-core/walkEncoding'
2
+ import { Dialog } from '@jbrowse/core/ui'
3
+ import {
4
+ Button,
5
+ DialogActions,
6
+ DialogContent,
7
+ MenuItem,
8
+ TextField,
9
+ Typography,
10
+ } from '@mui/material'
11
+ import { observer } from 'mobx-react'
12
+
13
+ import type { GraphPaneModel } from '../model'
14
+
15
+ type HighlightColors = Pick<
16
+ GraphPaneModel,
17
+ 'drawnWalks' | 'walkLabel' | 'setWalkColor'
18
+ >
19
+
20
+ const HighlightColorDialog = observer(function HighlightColorDialog({
21
+ model,
22
+ onClose,
23
+ }: {
24
+ model: HighlightColors
25
+ onClose: () => void
26
+ }) {
27
+ return (
28
+ <Dialog open onClose={onClose} title="Color highlighted haplotypes">
29
+ <DialogContent
30
+ sx={{
31
+ display: 'grid',
32
+ gridTemplateColumns: 'auto 1fr 1fr',
33
+ alignItems: 'center',
34
+ gap: 1.5,
35
+ }}
36
+ >
37
+ <Typography variant="subtitle2">Haplotype</Typography>
38
+ <Typography variant="subtitle2">Color by</Typography>
39
+ <Typography variant="subtitle2">Palette</Typography>
40
+ {model.drawnWalks.map(({ name, encoding }) => (
41
+ <Row
42
+ key={name}
43
+ label={model.walkLabel(name)}
44
+ field={encoding.field}
45
+ scheme={encoding.scheme}
46
+ onField={field => {
47
+ model.setWalkColor(name, { field })
48
+ }}
49
+ onScheme={scheme => {
50
+ model.setWalkColor(name, { scheme })
51
+ }}
52
+ />
53
+ ))}
54
+ </DialogContent>
55
+ <DialogActions>
56
+ <Button variant="contained" onClick={onClose}>
57
+ Done
58
+ </Button>
59
+ </DialogActions>
60
+ </Dialog>
61
+ )
62
+ })
63
+
64
+ type Field = (typeof WALK_FIELDS)[number]['value']
65
+ type Scheme = (typeof WALK_SCHEMES)[number]['value']
66
+
67
+ function Row({
68
+ label,
69
+ field,
70
+ scheme,
71
+ onField,
72
+ onScheme,
73
+ }: {
74
+ label: string
75
+ field: Field
76
+ scheme: Scheme
77
+ onField: (field: Field) => void
78
+ onScheme: (scheme: Scheme) => void
79
+ }) {
80
+ return (
81
+ <>
82
+ <Typography variant="body2">{label}</Typography>
83
+ <TextField
84
+ select
85
+ size="small"
86
+ value={field}
87
+ onChange={e => {
88
+ onField(e.target.value as Field)
89
+ }}
90
+ >
91
+ {WALK_FIELDS.map(f => (
92
+ <MenuItem key={f.value} value={f.value}>
93
+ {f.label}
94
+ </MenuItem>
95
+ ))}
96
+ </TextField>
97
+ <TextField
98
+ select
99
+ size="small"
100
+ value={scheme}
101
+ onChange={e => {
102
+ onScheme(e.target.value as Scheme)
103
+ }}
104
+ >
105
+ {WALK_SCHEMES.filter(
106
+ s => s.value !== 'rainbow' || field === 'reference',
107
+ ).map(s => (
108
+ <MenuItem key={s.value} value={s.value}>
109
+ {s.label}
110
+ </MenuItem>
111
+ ))}
112
+ </TextField>
113
+ </>
114
+ )
115
+ }
116
+
117
+ export default HighlightColorDialog
@@ -61,7 +61,7 @@ const TubeMapOverlay = observer(function TubeMapOverlay({
61
61
  const tubeFrame = {
62
62
  ...frame,
63
63
  width,
64
- highlightNode: model.hoveredNode ?? model.selectedNode,
64
+ highlightNode: model.litNode ?? model.selectedNode,
65
65
  darkMode: model.darkMode,
66
66
  tubeColors: model.tubeMapTubeColors,
67
67
  nodeColors: model.tubeMapNodeColors,
@@ -1522,6 +1522,20 @@ describe('hoverHighlight', () => {
1522
1522
  expect(model.hoverHighlight).toMatchObject({ start: 0, end: 4 })
1523
1523
  })
1524
1524
 
1525
+ test('turning hover highlighting off lights nothing', async () => {
1526
+ const model = await loadedFromTrack()
1527
+ model.setHighlightOnHover(false)
1528
+ model.setHoveredNode('2+')
1529
+ expect(model.hoverHighlight).toBeUndefined()
1530
+ expect(model.litNode).toBeNull()
1531
+ const lit: ReadonlyMap<string, number>[] = []
1532
+ model.applyHighlights({
1533
+ ...fakeRenderer(),
1534
+ setNodeHighlights: (m: ReadonlyMap<string, number>) => lit.push(m),
1535
+ })
1536
+ expect(lit).toEqual([new Map()])
1537
+ })
1538
+
1525
1539
  // A whole-file import has no region, so its stable names need not name anything
1526
1540
  // in a loaded assembly and there is nothing to project onto.
1527
1541
  test('a whole-file import publishes no highlight', async () => {
@@ -2682,10 +2696,8 @@ interface MenuItem {
2682
2696
  subMenu?: MenuItem[]
2683
2697
  }
2684
2698
 
2685
- function walkMenu(model: { graphMenuItems: () => unknown }) {
2686
- return (model.graphMenuItems() as MenuItem[]).find(
2687
- item => item.label === 'Walk',
2688
- )!
2699
+ function highlightMenu(model: { highlightMenuItems: () => unknown }) {
2700
+ return model.highlightMenuItems() as MenuItem[]
2689
2701
  }
2690
2702
 
2691
2703
  describe('walk rows', () => {
@@ -2793,7 +2805,7 @@ describe('walk rows', () => {
2793
2805
  await model.loadGFA(WALKS_GFA, 'walks')
2794
2806
  const samples = () =>
2795
2807
  (
2796
- (model.graphMenuItems() as MenuItem[]).find(
2808
+ (model.layoutOptionMenuItems() as MenuItem[]).find(
2797
2809
  item => item.label === 'Samples',
2798
2810
  ) as { subMenu: (MenuItem & { onClick: () => void })[] }
2799
2811
  ).subMenu
@@ -2934,13 +2946,9 @@ describe('walk rows', () => {
2934
2946
  // lifted together, each walk is one flat colour
2935
2947
  const ref = model.walkLift!.walks[0]!
2936
2948
  expect(new Set(ref.colors.values()).size).toBe(1)
2937
- const menu = walkMenu(model)
2938
- const colour = menu.subMenu!.find(item => item.label === 'Colour B#1')!
2939
2949
  expect(
2940
- colour.subMenu!.filter(item => item.checked).map(item => item.label),
2941
- ).toEqual(['One colour for the walk', 'Lime to forest'])
2942
- // the rainbow is offered for reference position only
2943
- expect(colour.subMenu!.some(item => item.label === 'Rainbow')).toBe(false)
2950
+ model.drawnWalks.find(w => w.name === 'B#1#ctg')!.encoding,
2951
+ ).toMatchObject({ field: 'walk', scheme: 'green' })
2944
2952
  model.liftWalks(['B#1#ctg'])
2945
2953
  expect(model.walkLayers).toEqual([
2946
2954
  { walk: 'B#1#ctg', color: { field: 'walk', scheme: 'green' } },
@@ -2978,8 +2986,7 @@ describe('walk rows', () => {
2978
2986
  })
2979
2987
  await model.loadGFA(WALKS_GFA, 'walks')
2980
2988
  model.liftWalks(['B#1#ctg', 'A#1#ctg'])
2981
- const menu = walkMenu(model)
2982
- expect(menu.subMenu!.some(item => item.label === 'Side by side')).toBe(
2989
+ expect(highlightMenu(model).some(item => item.label === 'Arrange')).toBe(
2983
2990
  false,
2984
2991
  )
2985
2992
  model.setFacet('walk')
@@ -3048,13 +3055,9 @@ describe('walk rows', () => {
3048
3055
  expect(model.canvasHeight).toBe(Math.max(160, grid.total))
3049
3056
  expect(model.scale).toBeCloseTo(grid.scale, 10)
3050
3057
  expect(model.scale).not.toBeCloseTo(whole, 10)
3051
- const menu = walkMenu(model)
3052
- const colour = menu.subMenu!.find(item => item.label === 'Colour B#1')!
3053
3058
  expect(
3054
- colour.subMenu!.filter(item => item.checked).map(item => item.label),
3055
- ).toEqual(['Progress along the walk', 'Yellow to red'])
3056
- const columns = menu.subMenu!.find(item => item.label === 'Columns')!
3057
- expect(columns.subMenu!.map(item => item.label)).toEqual(['Auto', '1', '2'])
3059
+ model.drawnWalks.find(w => w.name === 'B#1#ctg')!.encoding,
3060
+ ).toMatchObject({ field: 'progress', scheme: 'red' })
3058
3061
  })
3059
3062
 
3060
3063
  test('by sample, a sample takes a row and its haplotypes the columns', async () => {
@@ -3126,11 +3129,7 @@ describe('walk rows', () => {
3126
3129
  await model.loadGFA(WALKS_GFA, 'walks')
3127
3130
  expect(model.walkLift).toBeUndefined()
3128
3131
  expect(model.facetPanels).toBeUndefined()
3129
- expect(
3130
- (model.graphMenuItems() as { label?: string }[]).some(
3131
- item => item.label === 'Walk',
3132
- ),
3133
- ).toBe(false)
3132
+ expect(model.highlightMenuItems()).toEqual([])
3134
3133
  })
3135
3134
 
3136
3135
  test('a lane reads the reference ramp only when it is coloured by it', async () => {
@@ -3841,12 +3840,12 @@ describe('walk rows grouped by a sample column', () => {
3841
3840
  expect(stale.walkRowPlacement!.headers).toEqual([])
3842
3841
  })
3843
3842
 
3844
- test('Group by… lists None and each column, and the SVG export waits for None', async () => {
3843
+ test('Group by... lists None and each column, and the SVG export waits for None', async () => {
3845
3844
  const model = await groupedModel()
3846
3845
  const groupBy = () =>
3847
3846
  (
3848
- (model.graphMenuItems() as MenuItem[]).find(
3849
- item => item.label === 'Group by…',
3847
+ (model.layoutOptionMenuItems() as MenuItem[]).find(
3848
+ item => item.label === 'Group by...',
3850
3849
  ) as { subMenu: (MenuItem & { onClick: () => void })[] }
3851
3850
  ).subMenu
3852
3851
  expect(groupBy().map(item => [item.label, item.checked])).toEqual([
@@ -3857,19 +3856,96 @@ describe('walk rows grouped by a sample column', () => {
3857
3856
  groupBy()[2]!.onClick()
3858
3857
  expect(model.walkRowGroupBy).toEqual({ field: 'superpopulation' })
3859
3858
  expect(groupBy()[2]!.checked).toBe(true)
3860
- expect(model.figureUnavailable).toContain('Group by…')
3859
+ expect(model.figureUnavailable).toContain('Group by...')
3861
3860
  groupBy()[0]!.onClick()
3862
3861
  expect(model.walkRowGroupBy).toBeUndefined()
3863
3862
  expect(model.figureUnavailable).toBeUndefined()
3864
3863
  })
3865
3864
 
3866
- test('no samples table, no Group by…', async () => {
3865
+ test('no samples table, no Group by...', async () => {
3867
3866
  mockSession.tracks = []
3868
3867
  const model = await groupedModel()
3869
3868
  expect(
3870
- (model.graphMenuItems() as MenuItem[]).some(
3871
- item => item.label === 'Group by…',
3869
+ (model.layoutOptionMenuItems() as MenuItem[]).some(
3870
+ item => item.label === 'Group by...',
3872
3871
  ),
3873
3872
  ).toBe(false)
3874
3873
  })
3875
3874
  })
3875
+
3876
+ describe('keeping the drawing on screen', () => {
3877
+ function screenBox(model: ReturnType<typeof createModel>) {
3878
+ const b = model.layoutBounds!
3879
+ const left = b.minX * model.scaleX + model.translateX
3880
+ const top = b.minY * model.scaleY + model.translateY
3881
+ return {
3882
+ left,
3883
+ top,
3884
+ right: left + b.w * model.scaleX,
3885
+ bottom: top + b.h * model.scaleY,
3886
+ }
3887
+ }
3888
+
3889
+ async function forceModel() {
3890
+ rpcRespond()
3891
+ const model = createModel()
3892
+ await model.loadGFA(SIMPLE_GFA, 'simple')
3893
+ model.startRenderingBackend(fakeRenderer())
3894
+ model.setWidth(800)
3895
+ return model
3896
+ }
3897
+
3898
+ test('a pan stops with some of the drawing still in the pane', async () => {
3899
+ const model = await forceModel()
3900
+ model.setTransform(model.scale, model.translateX + 10_000, -10_000)
3901
+ const box = screenBox(model)
3902
+ expect(box.left).toBeLessThanOrEqual(model.viewBox.width - 48 + 1e-6)
3903
+ expect(box.bottom).toBeGreaterThanOrEqual(48 - 1e-6)
3904
+ })
3905
+
3906
+ test('a zoom about a point far from the drawing keeps it in the pane', async () => {
3907
+ const model = await forceModel()
3908
+ model.zoom(50, model.viewBox.width, model.viewBox.height)
3909
+ const box = screenBox(model)
3910
+ expect(box.right).toBeGreaterThan(0)
3911
+ expect(box.left).toBeLessThan(model.viewBox.width)
3912
+ expect(box.bottom).toBeGreaterThan(0)
3913
+ expect(box.top).toBeLessThan(model.viewBox.height)
3914
+ })
3915
+
3916
+ // A fresh force run, or a new cut as a linear view moves, lands in
3917
+ // coordinates of its own; the old zoom showed empty paper
3918
+ test('a new force layout refits a drawing the user had moved', async () => {
3919
+ const model = await forceModel()
3920
+ model.zoom(4, 0, 0)
3921
+ expect(model.viewportOwner).toBe('user')
3922
+ const shifted = {
3923
+ nodePositions: Object.fromEntries(
3924
+ Object.entries(MOCK_LAYOUT.nodePositions).map(([k, segs]) => [
3925
+ k,
3926
+ segs.map(p => ({ x: p.x + 5000, y: p.y - 3000 })),
3927
+ ]),
3928
+ ),
3929
+ }
3930
+ mockRpcCall.mockImplementation((_sid: unknown, method: string) =>
3931
+ method === 'GraphComputeLayout'
3932
+ ? Promise.resolve({ result: shifted, duration: 5 })
3933
+ : Promise.reject(new Error(`Unexpected RPC: ${method}`)),
3934
+ )
3935
+ await model.toggleDeletionEdges()
3936
+
3937
+ expect(model.viewportOwner).toBe('fit')
3938
+ expect(model.scale).toBeCloseTo(model.fittedTransform!.scale, 10)
3939
+ expect(model.translateX).toBeCloseTo(model.fittedTransform!.translateX, 10)
3940
+ })
3941
+
3942
+ test('a layout handed back unchanged keeps the user’s zoom', async () => {
3943
+ const model = await forceModel()
3944
+ model.zoom(4, 100, 50)
3945
+ const { scale, translateX } = model
3946
+ await model.recomputeLayout()
3947
+ expect(model.viewportOwner).toBe('user')
3948
+ expect(model.scale).toBe(scale)
3949
+ expect(model.translateX).toBe(translateX)
3950
+ })
3951
+ })
@@ -438,16 +438,19 @@ export const withFitViews = withHostViews
438
438
  // there is nothing to restore and nothing to go stale when a rebuild
439
439
  // renumbers the batch
440
440
  applyHighlights(b: Renderer) {
441
- const { hoveredNode, hoveredEdge, selectedNode } = self
441
+ const { litNode, hoveredEdge, selectedNode } = self
442
442
  const nodes = new Map<string, number>()
443
443
  if (selectedNode !== null) {
444
444
  nodes.set(selectedNode, SELECT_BRIGHTEN)
445
445
  }
446
- if (hoveredNode !== null && hoveredNode !== selectedNode) {
447
- nodes.set(hoveredNode, HOVER_BRIGHTEN)
446
+ if (litNode !== null && litNode !== selectedNode) {
447
+ nodes.set(litNode, HOVER_BRIGHTEN)
448
448
  }
449
449
  b.setNodeHighlights(nodes)
450
- b.setEdgeHighlight(hoveredEdge, HOVER_BRIGHTEN)
450
+ b.setEdgeHighlight(
451
+ self.chosenHighlightOnHover ? hoveredEdge : null,
452
+ HOVER_BRIGHTEN,
453
+ )
451
454
  },
452
455
  // Draws the uploaded batch through the pane's transform
453
456
  paint(b: Renderer) {
@@ -627,7 +630,7 @@ export const withFitViews = withHostViews
627
630
  return !self.layoutResult
628
631
  ? 'Nothing is drawn yet'
629
632
  : self.walkRowGroups
630
- ? 'The SVG export cannot group walk rows yet: set Group by… to None first'
633
+ ? 'The SVG export cannot group walk rows yet: set Group by... to None first'
631
634
  : self.drawsNodes || self.walkRowBars
632
635
  ? undefined
633
636
  : `${layoutModeByValue(self.chosenLayoutMode).label} draws a picture of its own, which the SVG export does not`
@@ -79,6 +79,9 @@ export const withGraphViews = paneBase
79
79
  get defaultColorScheme(): ColorScheme {
80
80
  return 'auto'
81
81
  },
82
+ get defaultHighlightOnHover(): boolean {
83
+ return true
84
+ },
82
85
  // a source declared but not yet loaded
83
86
  get hasPendingSource() {
84
87
  return false
@@ -102,6 +105,13 @@ export const withGraphViews = paneBase
102
105
  get chosenColorScheme() {
103
106
  return self.colorScheme ?? self.defaultColorScheme
104
107
  },
108
+ get chosenHighlightOnHover() {
109
+ return self.highlightOnHover ?? self.defaultHighlightOnHover
110
+ },
111
+ // the hovered node, where hovering is to light it
112
+ get litNode() {
113
+ return this.chosenHighlightOnHover ? self.hoveredNode : null
114
+ },
105
115
  }))
106
116
  .views(self => ({
107
117
  get paneWidth() {
@@ -877,7 +887,7 @@ export const withGraphViews = paneBase
877
887
  get hoverHighlight() {
878
888
  const region = self.graphRegion
879
889
  const span = self.hoveredSpan
880
- return region && span
890
+ return region && span && self.chosenHighlightOnHover
881
891
  ? {
882
892
  refName: region.refName,
883
893
  assemblyName: region.assemblyName,
@@ -57,7 +57,7 @@ export const withHostViews = withWalkRowViews
57
57
  },
58
58
  get tubeMapPicture() {
59
59
  const drawing = self.layoutResult?.tubeMap
60
- return drawing ? tubeMapPicture(drawing.layout) : undefined
60
+ return drawing ? tubeMapPicture(drawing) : undefined
61
61
  },
62
62
  get tubeMapReference() {
63
63
  const drawing = self.layoutResult?.tubeMap
@@ -0,0 +1,62 @@
1
+ // How much of the drawing a pan or zoom leaves on screen in each axis, or
2
+ // all of it when it is smaller
3
+ export const KEEP_IN_VIEW_PX = 48
4
+
5
+ // The shift that brings a span starting at `lo`, `size` px long, back to
6
+ // overlapping [0, extent] by `margin` px
7
+ function shiftIntoView(lo: number, size: number, extent: number) {
8
+ const margin = Math.min(KEEP_IN_VIEW_PX, size, extent)
9
+ return Math.max(0, margin - (lo + size)) - Math.max(0, lo - (extent - margin))
10
+ }
11
+
12
+ interface DrawingBox {
13
+ minX: number
14
+ minY: number
15
+ w: number
16
+ h: number
17
+ }
18
+
19
+ interface Placement {
20
+ scaleX: number
21
+ scaleY: number
22
+ translateX: number
23
+ translateY: number
24
+ }
25
+
26
+ export function keepInView(
27
+ bounds: DrawingBox,
28
+ t: Placement,
29
+ view: { width: number; height: number },
30
+ ) {
31
+ return {
32
+ translateX:
33
+ t.translateX +
34
+ shiftIntoView(
35
+ bounds.minX * t.scaleX + t.translateX,
36
+ bounds.w * t.scaleX,
37
+ view.width,
38
+ ),
39
+ translateY:
40
+ t.translateY +
41
+ shiftIntoView(
42
+ bounds.minY * t.scaleY + t.translateY,
43
+ bounds.h * t.scaleY,
44
+ view.height,
45
+ ),
46
+ }
47
+ }
48
+
49
+ export function onScreen(
50
+ bounds: DrawingBox,
51
+ t: Placement,
52
+ view: { width: number; height: number },
53
+ ) {
54
+ const left = bounds.minX * t.scaleX + t.translateX
55
+ const top = bounds.minY * t.scaleY + t.translateY
56
+ return (
57
+ left < view.width &&
58
+ left + bounds.w * t.scaleX > 0 &&
59
+ top < view.height &&
60
+ top + bounds.h * t.scaleY > 0
61
+ )
62
+ }