jbrowse-plugin-graphgenomeviewer 4.0.13 → 4.0.14

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 (53) hide show
  1. package/README.md +5 -1
  2. package/dist/chunks/{GbzBaseSyntenyAdapter-Y5NBXK7P.js → GbzBaseSyntenyAdapter-3ZTHTTHA.js} +2 -2
  3. package/dist/chunks/{GraphGenomeView-TTQEJJQD.js → GraphGenomeView-35HMVXRP.js} +2 -2
  4. package/dist/chunks/{GraphTrackSettingsDialog-3A2PKZRI.js → GraphTrackSettingsDialog-2QV6TPX7.js} +2 -2
  5. package/dist/chunks/{LinearGraphDisplay-MJ5XZFE5.js → LinearGraphDisplay-3M3LNC5H.js} +2 -2
  6. package/dist/chunks/{MinigraphBubbleAdapter-APC7JYOM.js → MinigraphBubbleAdapter-NRDD66AK.js} +2 -2
  7. package/dist/chunks/{RgfaTabixAdapter-4E365PXO.js → RgfaTabixAdapter-NQEERXXI.js} +2 -2
  8. package/dist/chunks/{chunk-5DP7IXD3.js → chunk-33VHHB43.js} +2 -2
  9. package/dist/chunks/{chunk-LA7MQS33.js → chunk-5E7TVPCI.js} +2 -2
  10. package/dist/chunks/chunk-IUIIYSGF.js +2 -0
  11. package/dist/chunks/{chunk-M4YEAXSR.js.map → chunk-IUIIYSGF.js.map} +3 -3
  12. package/dist/chunks/chunk-JXAJ7YLT.js +2 -0
  13. package/dist/chunks/chunk-JXAJ7YLT.js.map +7 -0
  14. package/dist/chunks/chunk-KULNKXPT.js +2 -0
  15. package/dist/chunks/chunk-KULNKXPT.js.map +7 -0
  16. package/dist/chunks/chunk-QSH263Y6.js +2 -0
  17. package/dist/chunks/chunk-QSH263Y6.js.map +7 -0
  18. package/dist/chunks/chunk-UQEPPJEO.js +2 -0
  19. package/dist/chunks/{chunk-VAGPKHHS.js.map → chunk-UQEPPJEO.js.map} +3 -3
  20. package/dist/chunks/chunk-YWJ3RWYB.js +4 -0
  21. package/dist/chunks/chunk-YWJ3RWYB.js.map +7 -0
  22. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js +2 -2
  23. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js.map +3 -3
  24. package/package.json +2 -2
  25. package/src/GraphGenomeView/components/BubbleHalos.tsx +67 -3
  26. package/src/GraphGenomeView/components/BubbleOverlay.tsx +6 -0
  27. package/src/GraphGenomeView/components/GraphCanvas.tsx +46 -9
  28. package/src/GraphGenomeView/components/LabelChip.tsx +9 -1
  29. package/src/GraphGenomeView/components/LabelLayer.tsx +3 -0
  30. package/src/GraphGenomeView/components/ReferenceStripOverlay.tsx +116 -0
  31. package/src/GraphGenomeView/components/TubeMapOverlay.tsx +109 -41
  32. package/src/GraphGenomeView/model.test.ts +3 -0
  33. package/src/GraphGenomeView/model.ts +204 -43
  34. package/src/LinearGraphDisplay/model.ts +2 -0
  35. package/src/version.ts +1 -1
  36. package/dist/chunks/chunk-CJ56O2VG.js +0 -2
  37. package/dist/chunks/chunk-CJ56O2VG.js.map +0 -7
  38. package/dist/chunks/chunk-IFRDWMQ2.js +0 -2
  39. package/dist/chunks/chunk-IFRDWMQ2.js.map +0 -7
  40. package/dist/chunks/chunk-M4YEAXSR.js +0 -2
  41. package/dist/chunks/chunk-MUK3KYB4.js +0 -2
  42. package/dist/chunks/chunk-MUK3KYB4.js.map +0 -7
  43. package/dist/chunks/chunk-VAGPKHHS.js +0 -2
  44. package/dist/chunks/chunk-XDOIZTY6.js +0 -4
  45. package/dist/chunks/chunk-XDOIZTY6.js.map +0 -7
  46. /package/dist/chunks/{GbzBaseSyntenyAdapter-Y5NBXK7P.js.map → GbzBaseSyntenyAdapter-3ZTHTTHA.js.map} +0 -0
  47. /package/dist/chunks/{GraphGenomeView-TTQEJJQD.js.map → GraphGenomeView-35HMVXRP.js.map} +0 -0
  48. /package/dist/chunks/{GraphTrackSettingsDialog-3A2PKZRI.js.map → GraphTrackSettingsDialog-2QV6TPX7.js.map} +0 -0
  49. /package/dist/chunks/{LinearGraphDisplay-MJ5XZFE5.js.map → LinearGraphDisplay-3M3LNC5H.js.map} +0 -0
  50. /package/dist/chunks/{MinigraphBubbleAdapter-APC7JYOM.js.map → MinigraphBubbleAdapter-NRDD66AK.js.map} +0 -0
  51. /package/dist/chunks/{RgfaTabixAdapter-4E365PXO.js.map → RgfaTabixAdapter-NQEERXXI.js.map} +0 -0
  52. /package/dist/chunks/{chunk-5DP7IXD3.js.map → chunk-33VHHB43.js.map} +0 -0
  53. /package/dist/chunks/{chunk-LA7MQS33.js.map → chunk-5E7TVPCI.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jbrowse-plugin-graphgenomeviewer",
3
- "version": "4.0.13",
3
+ "version": "4.0.14",
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": {
@@ -76,7 +76,7 @@
76
76
  "typescript-eslint": "^8.70.1",
77
77
  "typescript7": "npm:typescript@^7.0.2",
78
78
  "vitest": "^5.0.2",
79
- "@jbrowse/bandage-core": "0.1.8"
79
+ "@jbrowse/bandage-core": "0.1.9"
80
80
  },
81
81
  "peerDependencies": {
82
82
  "react": ">=18.0.0",
@@ -1,4 +1,8 @@
1
- import { BUBBLE_KIND_COLORS } from '@jbrowse/bandage-core/bubbles/classifyBubble'
1
+ import { sameBubble } from '@jbrowse/bandage-core/bubbles/bubbleLine'
2
+ import {
3
+ BUBBLE_KIND_COLORS,
4
+ BUBBLE_KIND_NAMES,
5
+ } from '@jbrowse/bandage-core/bubbles/classifyBubble'
2
6
  import { HALO_FACTOR } from '@jbrowse/bandage-core/labelLayout'
3
7
  import { observer } from 'mobx-react'
4
8
 
@@ -24,7 +28,7 @@ const BubbleHalos = observer(function BubbleHalos({
24
28
  }: {
25
29
  model: GraphPaneModel
26
30
  }) {
27
- const { bubbleHalos, walkHighlight } = model
31
+ const { bubbleHalos, walkHighlight, hoveredBubble } = model
28
32
  if (bubbleHalos.length === 0) {
29
33
  return null
30
34
  }
@@ -60,7 +64,13 @@ const BubbleHalos = observer(function BubbleHalos({
60
64
  d={h.path}
61
65
  fill="none"
62
66
  stroke={BUBBLE_KIND_COLORS[h.kind]}
63
- strokeOpacity={dimmedBubble(h) ? 0.06 : 0.22}
67
+ strokeOpacity={
68
+ hoveredBubble && sameBubble(h.bubble, hoveredBubble)
69
+ ? 0.45
70
+ : dimmedBubble(h)
71
+ ? 0.06
72
+ : 0.22
73
+ }
64
74
  strokeWidth={halo}
65
75
  strokeLinecap="round"
66
76
  strokeLinejoin="round"
@@ -73,3 +83,57 @@ const BubbleHalos = observer(function BubbleHalos({
73
83
  })
74
84
 
75
85
  export default BubbleHalos
86
+
87
+ const legendBoxStyle = {
88
+ background: 'rgba(255,255,255,0.82)',
89
+ padding: '4px 6px',
90
+ borderRadius: 3,
91
+ fontSize: 11,
92
+ lineHeight: '15px',
93
+ whiteSpace: 'nowrap' as const,
94
+ }
95
+ const legendRowStyle = { display: 'flex', alignItems: 'center', gap: 5 }
96
+
97
+ function HaloSwatch({ color }: { color: string }) {
98
+ return (
99
+ <svg width={18} height={10} style={{ flex: 'none' }}>
100
+ <line
101
+ x1={5}
102
+ y1={5}
103
+ x2={13}
104
+ y2={5}
105
+ stroke={color}
106
+ strokeOpacity={0.35}
107
+ strokeWidth={8}
108
+ strokeLinecap="round"
109
+ />
110
+ </svg>
111
+ )
112
+ }
113
+
114
+ // One row per halo colour on screen, naming the kinds it stands for
115
+ export const HaloLegend = observer(function HaloLegend({
116
+ model,
117
+ }: {
118
+ model: GraphPaneModel
119
+ }) {
120
+ const kindsByColor = new Map<string, Set<string>>()
121
+ for (const h of model.bubbleHalos) {
122
+ if (!h.whole) {
123
+ const color = BUBBLE_KIND_COLORS[h.kind]
124
+ const kinds = kindsByColor.get(color) ?? new Set<string>()
125
+ kinds.add(BUBBLE_KIND_NAMES[h.kind])
126
+ kindsByColor.set(color, kinds)
127
+ }
128
+ }
129
+ return kindsByColor.size > 0 ? (
130
+ <div style={legendBoxStyle} data-testid="graph-halo-legend">
131
+ {[...kindsByColor].map(([color, kinds]) => (
132
+ <div key={color} style={legendRowStyle}>
133
+ <HaloSwatch color={color} />
134
+ <span>bubble: {[...kinds].join(', ')}</span>
135
+ </div>
136
+ ))}
137
+ </div>
138
+ ) : null
139
+ })
@@ -111,6 +111,12 @@ const BubbleOverlay = observer(function BubbleOverlay({
111
111
  onClick={() => {
112
112
  void model.popBubble(bubble)
113
113
  }}
114
+ onMouseEnter={() => {
115
+ model.setHoveredBubble(bubble)
116
+ }}
117
+ onMouseLeave={() => {
118
+ model.setHoveredBubble(null)
119
+ }}
114
120
  >
115
121
  <title>{`${label}\n${bubble.segmentCount} segments · click to open`}</title>
116
122
  </path>
@@ -13,11 +13,14 @@ import { useRenderingBackend } from '@jbrowse/render-core/useRenderingBackend'
13
13
  import InfoIcon from '@mui/icons-material/Info'
14
14
  import { observer } from 'mobx-react'
15
15
 
16
- import BubbleHalos from './BubbleHalos'
16
+ import BubbleHalos, { HaloLegend } from './BubbleHalos'
17
17
  import BubbleOverlay from './BubbleOverlay'
18
18
  import GenePins from './GenePins'
19
19
  import GraphToolbar from './GraphToolbar'
20
20
  import LabelLayer from './LabelLayer'
21
+ import ReferenceStripOverlay, {
22
+ ReferenceStripLegend,
23
+ } from './ReferenceStripOverlay'
21
24
  import TubeMapOverlay, { TubeMapLegend } from './TubeMapOverlay'
22
25
  import WalkRowsOverlay, { WalkRowsLegend } from './WalkRowsOverlay'
23
26
  import { RAMP_GRADIENT_CSS } from './referenceRampCss'
@@ -195,6 +198,8 @@ const WalkReadout = observer(function WalkReadout({
195
198
  <div style={legendBoxStyle} data-testid="graph-walk-readout">
196
199
  <strong>{label}</strong>: {h.steps.toLocaleString()} steps,{' '}
197
200
  {h.bp.toLocaleString()} bp{delta}
201
+ <br />
202
+ paler: what {label} does not carry
198
203
  </div>
199
204
  )
200
205
  })
@@ -291,8 +296,16 @@ const Legends = observer(function Legends({
291
296
  }
292
297
  }, [model])
293
298
  return (
294
- <div ref={ref} style={legendStackStyle}>
299
+ <div
300
+ ref={ref}
301
+ style={{
302
+ ...legendStackStyle,
303
+ top: LEGEND_INSET_PX + model.referenceStripZonePx,
304
+ }}
305
+ >
295
306
  <ReferenceRampLegend model={model} />
307
+ <ReferenceStripLegend model={model} />
308
+ <HaloLegend model={model} />
296
309
  <PathLegend model={model} />
297
310
  <TubeMapLegend model={model} />
298
311
  <WalkRowsLegend model={model} />
@@ -564,11 +577,14 @@ const GraphCanvas = observer(function GraphCanvas({
564
577
  // has one does not pay for a second getBoundingClientRect.
565
578
  function nodeAt(x: number, y: number) {
566
579
  const { nodePositions } = model
580
+ const sx = x * model.scaleX + model.translateX
581
+ const sy = y * model.scaleY + model.translateY
567
582
  if (model.layoutResult?.tubeMap) {
568
- return model.tubeMapNodeAt(
569
- x * model.scaleX + model.translateX,
570
- y * model.scaleY + model.translateY,
571
- )
583
+ return model.tubeMapNodeAt(sx, sy)
584
+ }
585
+ const onStrip = model.referenceStripNodeAt(sx, sy)
586
+ if (onStrip) {
587
+ return onStrip
572
588
  }
573
589
  return nodePositions
574
590
  ? findHoveredNode(
@@ -592,8 +608,13 @@ const GraphCanvas = observer(function GraphCanvas({
592
608
  e.stopPropagation()
593
609
  }
594
610
  const { x, y } = getMouseCoord(e)
595
- // a tube map's boxes are the layout's, not positions to drag
596
- const node = model.layoutResult?.tubeMap ? null : nodeAt(x, y)
611
+ // a tube map's boxes are the layout's, and the strip's are bp, not
612
+ // positions to drag
613
+ const node =
614
+ model.layoutResult?.tubeMap ||
615
+ e.nativeEvent.offsetY < model.referenceStripZonePx
616
+ ? null
617
+ : nodeAt(x, y)
597
618
  if (node) {
598
619
  model.setDraggingNode(node)
599
620
  } else {
@@ -684,8 +705,13 @@ const GraphCanvas = observer(function GraphCanvas({
684
705
  model.stopDragging()
685
706
  }
686
707
 
708
+ // also fired by the canvas unmounting under a resting pointer, after the
709
+ // view closing it has destroyed the model
687
710
  function handleMouseLeave() {
688
711
  dropPending()
712
+ if (!isAlive(model)) {
713
+ return
714
+ }
689
715
  model.stopDragging()
690
716
  model.setHoveredNode(null)
691
717
  model.setHoveredEdge(null)
@@ -720,7 +746,17 @@ const GraphCanvas = observer(function GraphCanvas({
720
746
  <div style={wrapperStyle}>
721
747
  {ownChrome ? <GraphToolbar model={model} /> : null}
722
748
 
723
- <div style={canvasAreaStyle}>
749
+ <div
750
+ style={canvasAreaStyle}
751
+ onMouseEnter={() => {
752
+ model.setPointerInPane(true)
753
+ }}
754
+ onMouseLeave={() => {
755
+ if (isAlive(model)) {
756
+ model.setPointerInPane(false)
757
+ }
758
+ }}
759
+ >
724
760
  <canvas
725
761
  ref={canvasRef}
726
762
  data-testid="graph-genome-canvas"
@@ -739,6 +775,7 @@ const GraphCanvas = observer(function GraphCanvas({
739
775
  />
740
776
 
741
777
  <TubeMapOverlay model={model} />
778
+ <ReferenceStripOverlay model={model} />
742
779
  <RowLabels model={model} />
743
780
  <GraphSizeLabels model={model} />
744
781
  <BubbleHalos model={model} />
@@ -14,6 +14,7 @@ export default function LabelChip({
14
14
  title,
15
15
  testId,
16
16
  onClick,
17
+ onHover,
17
18
  }: {
18
19
  x: number
19
20
  y: number
@@ -26,16 +27,23 @@ export default function LabelChip({
26
27
  title?: string
27
28
  testId?: string
28
29
  onClick?: () => void
30
+ onHover?: (hovered: boolean) => void
29
31
  }) {
30
32
  return (
31
33
  <g
32
34
  style={{
33
- pointerEvents: onClick ? 'auto' : 'none',
35
+ pointerEvents: onClick || onHover ? 'auto' : 'none',
34
36
  cursor: onClick ? 'pointer' : undefined,
35
37
  opacity: dimmed ? 0.35 : 1,
36
38
  }}
37
39
  data-testid={testId}
38
40
  onClick={onClick}
41
+ onMouseEnter={() => {
42
+ onHover?.(true)
43
+ }}
44
+ onMouseLeave={() => {
45
+ onHover?.(false)
46
+ }}
39
47
  >
40
48
  {title ? <title>{title}</title> : null}
41
49
  <rect
@@ -100,6 +100,9 @@ const LabelLayer = observer(function LabelLayer({
100
100
  onClick={() => {
101
101
  void model.popBubble(h.bubble)
102
102
  }}
103
+ onHover={hovered => {
104
+ model.setHoveredBubble(hovered ? h.bubble : null)
105
+ }}
103
106
  />
104
107
  ))}
105
108
  </svg>
@@ -0,0 +1,116 @@
1
+ import { useEffect, useRef } from 'react'
2
+
3
+ import { drawReferenceStrip } from '@jbrowse/bandage-core/referenceStrip'
4
+ import { getDpr } from '@jbrowse/render-core/canvas2dUtils'
5
+ import { autorun } from 'mobx'
6
+ import { observer } from 'mobx-react'
7
+
8
+ import type { GraphPaneModel } from '../model'
9
+
10
+ const canvasStyle = {
11
+ position: 'absolute' as const,
12
+ left: 0,
13
+ top: 0,
14
+ pointerEvents: 'none' as const,
15
+ zIndex: 5,
16
+ }
17
+
18
+ // Each reference segment at its bp in the linear view, over a drawing that
19
+ // has no bp axis of its own. Repainted by an autorun, so a pan in the linear
20
+ // view moves the strip in the same frame as its other tracks.
21
+ const ReferenceStripOverlay = observer(function ReferenceStripOverlay({
22
+ model,
23
+ }: {
24
+ model: GraphPaneModel
25
+ }) {
26
+ const ref = useRef<HTMLCanvasElement>(null)
27
+ const { referenceStripShown, paneWidth, canvasHeight } = model
28
+ useEffect(
29
+ () =>
30
+ autorun(() => {
31
+ const canvas = ref.current
32
+ const frame = model.referenceStripFrame
33
+ const ctx = canvas?.getContext('2d')
34
+ if (canvas && ctx && frame && model.referenceStripShown) {
35
+ const dpr = getDpr()
36
+ const width = model.paneWidth
37
+ const height = model.canvasHeight
38
+ canvas.width = Math.round(width * dpr)
39
+ canvas.height = Math.round(height * dpr)
40
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
41
+ ctx.clearRect(0, 0, width, height)
42
+ drawReferenceStrip(ctx, model.referenceStripBlocks, frame, {
43
+ width,
44
+ lit: model.referenceStripLit,
45
+ darkMode: model.darkMode,
46
+ })
47
+ }
48
+ }),
49
+ [model],
50
+ )
51
+ return (
52
+ <canvas
53
+ ref={ref}
54
+ data-testid={referenceStripShown ? 'graph-reference-strip' : undefined}
55
+ style={{
56
+ ...canvasStyle,
57
+ width: paneWidth,
58
+ height: canvasHeight,
59
+ display: referenceStripShown ? 'block' : 'none',
60
+ }}
61
+ />
62
+ )
63
+ })
64
+
65
+ export default ReferenceStripOverlay
66
+
67
+ const legendBoxStyle = {
68
+ background: 'rgba(255,255,255,0.82)',
69
+ padding: '4px 6px',
70
+ borderRadius: 3,
71
+ fontSize: 11,
72
+ lineHeight: '15px',
73
+ whiteSpace: 'nowrap' as const,
74
+ }
75
+ const legendRowStyle = { display: 'flex', alignItems: 'center', gap: 5 }
76
+ const SWATCH_PX = 18
77
+
78
+ function StripSwatch() {
79
+ return (
80
+ <svg width={SWATCH_PX} height={4} style={{ flex: 'none' }}>
81
+ <rect x={0} y={0} width={6} height={4} fill="hsl(0, 70%, 50%)" />
82
+ <rect x={6} y={0} width={5} height={4} fill="hsl(120, 70%, 50%)" />
83
+ <rect x={11} y={0} width={7} height={4} fill="hsl(240, 70%, 50%)" />
84
+ </svg>
85
+ )
86
+ }
87
+
88
+ function OverhangSwatch() {
89
+ return (
90
+ <svg width={SWATCH_PX} height={8} style={{ flex: 'none' }}>
91
+ <path d="M0,4 L5,0.5 L5,7.5 Z M18,4 L13,0.5 L13,7.5 Z" fill="#18181c" />
92
+ </svg>
93
+ )
94
+ }
95
+
96
+ // Hovering a node boxes its span on the strip and draws a leader to it
97
+ export const ReferenceStripLegend = observer(function ReferenceStripLegend({
98
+ model,
99
+ }: {
100
+ model: GraphPaneModel
101
+ }) {
102
+ return model.referenceStripShown ? (
103
+ <div style={legendBoxStyle} data-testid="graph-reference-strip-legend">
104
+ <div style={legendRowStyle}>
105
+ <StripSwatch />
106
+ <span>top strip: reference segments at their bp</span>
107
+ </div>
108
+ {model.referenceStripOverhangs ? (
109
+ <div style={legendRowStyle}>
110
+ <OverhangSwatch />
111
+ <span>the graph runs past this edge</span>
112
+ </div>
113
+ ) : null}
114
+ </div>
115
+ ) : null
116
+ })
@@ -1,12 +1,8 @@
1
1
  import { useEffect, useRef } from 'react'
2
+ import type { ReactNode } from 'react'
2
3
 
3
4
  import { formatBp } from '@jbrowse/bandage-core/graphLabels'
4
- import {
5
- ZIGZAG_AMPLITUDE_PX,
6
- ZIGZAG_PX,
7
- drawTubeMapRuler,
8
- rulerInk,
9
- } from '@jbrowse/bandage-core/tubeMap/axis'
5
+ import { drawTubeMapRuler, rulerInk } from '@jbrowse/bandage-core/tubeMap/axis'
10
6
  import { drawTubeMapConnectors } from '@jbrowse/bandage-core/tubeMap/connectors'
11
7
  import {
12
8
  deviationInk,
@@ -14,6 +10,10 @@ import {
14
10
  } from '@jbrowse/bandage-core/tubeMap/deviations'
15
11
  import { drawTubeMap } from '@jbrowse/bandage-core/tubeMap/draw'
16
12
  import { drawTubeMapGenes } from '@jbrowse/bandage-core/tubeMap/genes'
13
+ import {
14
+ FORWARD_READ_COLORS,
15
+ REVERSE_READ_COLORS,
16
+ } from '@jbrowse/bandage-core/tubeMap/reads'
17
17
  import { getDpr } from '@jbrowse/render-core/canvas2dUtils'
18
18
  import { autorun } from 'mobx'
19
19
  import { observer } from 'mobx-react'
@@ -79,7 +79,13 @@ const TubeMapOverlay = observer(function TubeMapOverlay({
79
79
  )
80
80
  const boxes = model.tubeMapRulerBoxes
81
81
  if (boxes) {
82
- drawTubeMapRuler(ctx, boxes, tubeFrame, frame.y(bounds.maxY) + 4)
82
+ drawTubeMapRuler(
83
+ ctx,
84
+ boxes,
85
+ tubeFrame,
86
+ frame.y(bounds.maxY) + 4,
87
+ model.layoutResult?.referenceAxis,
88
+ )
83
89
  }
84
90
  }
85
91
  }),
@@ -114,22 +120,16 @@ const legendBoxStyle = {
114
120
  const legendRowStyle = { display: 'flex', alignItems: 'center', gap: 5 }
115
121
  const SWATCH_PX = 18
116
122
 
117
- // The ruler's own zigzag, tooth for tooth
118
- function ZigzagSwatch() {
119
- const teeth = Math.round(SWATCH_PX / ZIGZAG_PX)
120
- const dx = SWATCH_PX / teeth
121
- const mid = ZIGZAG_AMPLITUDE_PX + 1
122
- const points = [`0,${mid}`]
123
- for (let i = 0; i < teeth; i++) {
124
- points.push(
125
- `${(i + 0.5) * dx},${mid + (i % 2 ? 1 : -1) * ZIGZAG_AMPLITUDE_PX}`,
126
- )
127
- }
128
- points.push(`${SWATCH_PX},${mid}`)
123
+ // Two boxes' brackets, as the ruler draws them under every box
124
+ function BracketSwatch() {
125
+ const brackets = [
126
+ [0.5, 5.5],
127
+ [8.5, SWATCH_PX - 0.5],
128
+ ]
129
129
  return (
130
- <svg width={SWATCH_PX} height={mid * 2} style={{ flex: 'none' }}>
131
- <polyline
132
- points={points.join(' ')}
130
+ <svg width={SWATCH_PX} height={7} style={{ flex: 'none' }}>
131
+ <path
132
+ d={brackets.map(([x0, x1]) => `M${x0},6 V1.5 H${x1} V6`).join(' ')}
133
133
  fill="none"
134
134
  stroke={rulerInk()}
135
135
  strokeWidth={1}
@@ -138,10 +138,10 @@ function ZigzagSwatch() {
138
138
  )
139
139
  }
140
140
 
141
+ const barSwatchStyle = { width: SWATCH_PX, height: 7, flex: 'none' }
142
+
141
143
  const tubeSwatchStyle = {
142
- width: SWATCH_PX,
143
- height: 7,
144
- flex: 'none',
144
+ ...barSwatchStyle,
145
145
  display: 'flex',
146
146
  justifyContent: 'center',
147
147
  backgroundColor: '#b8b8c0',
@@ -155,28 +155,96 @@ function TickSwatch() {
155
155
  )
156
156
  }
157
157
 
158
- // The two marks a tube map draws that nothing else on screen explains, each
159
- // listed only while it is drawn
158
+ // A read takes one shade of its strand's palette, varied so neighbours differ
159
+ function ReadSwatch({ colors }: { colors: readonly string[] }) {
160
+ const step = 100 / colors.length
161
+ const stops = colors.map((c, i) => `${c} ${i * step}% ${(i + 1) * step}%`)
162
+ return (
163
+ <div
164
+ style={{
165
+ ...barSwatchStyle,
166
+ background: `linear-gradient(to right, ${stops.join(', ')})`,
167
+ }}
168
+ />
169
+ )
170
+ }
171
+
172
+ const glyphSwatchStyle = {
173
+ width: SWATCH_PX,
174
+ flex: 'none',
175
+ textAlign: 'center' as const,
176
+ fontFamily: 'monospace',
177
+ fontSize: 12,
178
+ }
179
+
180
+ function DeletionSwatch() {
181
+ return <div style={{ ...barSwatchStyle, backgroundColor: 'grey' }} />
182
+ }
183
+
184
+ function LegendRow({
185
+ swatch,
186
+ children,
187
+ }: {
188
+ swatch: ReactNode
189
+ children: ReactNode
190
+ }) {
191
+ return (
192
+ <div style={legendRowStyle}>
193
+ {swatch}
194
+ <span>{children}</span>
195
+ </div>
196
+ )
197
+ }
198
+
199
+ // What a tube map draws that nothing else on screen explains, each listed
200
+ // only while it applies
160
201
  export const TubeMapLegend = observer(function TubeMapLegend({
161
202
  model,
162
203
  }: {
163
204
  model: GraphPaneModel
164
205
  }) {
165
- const { squeezed, foldBp } = model.tubeMapKeys
166
- return squeezed || foldBp !== undefined ? (
206
+ const { logWidths, foldBp, forwardReads, reverseReads, mismatches } =
207
+ model.tubeMapKeys
208
+ const rows = [
209
+ forwardReads && (
210
+ <LegendRow key="fwd" swatch={<ReadSwatch colors={FORWARD_READ_COLORS} />}>
211
+ read on the forward strand
212
+ </LegendRow>
213
+ ),
214
+ reverseReads && (
215
+ <LegendRow key="rev" swatch={<ReadSwatch colors={REVERSE_READ_COLORS} />}>
216
+ read on the reverse strand
217
+ </LegendRow>
218
+ ),
219
+ mismatches.has('substitution') && (
220
+ <LegendRow key="sub" swatch={<span style={glyphSwatchStyle}>A</span>}>
221
+ a read's base unlike the node's
222
+ </LegendRow>
223
+ ),
224
+ mismatches.has('insertion') && (
225
+ <LegendRow key="ins" swatch={<span style={glyphSwatchStyle}>*</span>}>
226
+ bases a read inserts
227
+ </LegendRow>
228
+ ),
229
+ mismatches.has('deletion') && (
230
+ <LegendRow key="del" swatch={<DeletionSwatch />}>
231
+ bases a read skips
232
+ </LegendRow>
233
+ ),
234
+ foldBp !== undefined && (
235
+ <LegendRow key="fold" swatch={<TickSwatch />}>
236
+ a haplotype's variant under {formatBp(foldBp)}
237
+ </LegendRow>
238
+ ),
239
+ logWidths && (
240
+ <LegendRow key="log" swatch={<BracketSwatch />}>
241
+ width grows with log of length
242
+ </LegendRow>
243
+ ),
244
+ ].filter(Boolean)
245
+ return rows.length > 0 ? (
167
246
  <div style={legendBoxStyle} data-testid="graph-tube-map-legend">
168
- {foldBp !== undefined ? (
169
- <div style={legendRowStyle}>
170
- <TickSwatch />
171
- <span>a haplotype's variant under {formatBp(foldBp)}</span>
172
- </div>
173
- ) : null}
174
- {squeezed ? (
175
- <div style={legendRowStyle}>
176
- <ZigzagSwatch />
177
- <span>ruler not to scale, box length below</span>
178
- </div>
179
- ) : null}
247
+ {rows}
180
248
  </div>
181
249
  ) : null
182
250
  })
@@ -2398,6 +2398,9 @@ describe('popping a bubble', () => {
2398
2398
  model.setLayoutMode('ordered')
2399
2399
  await model.loadGFA(RGFA_BUBBLE, 'rgfa')
2400
2400
  expect(model.bubbleGlyphs).toEqual([])
2401
+ // off by default
2402
+ expect(model.bubbleHalos).toEqual([])
2403
+ model.setShowBubbles(true)
2401
2404
  expect(
2402
2405
  model.bubbleHalos.map(h => [h.label, h.members, h.path.startsWith('M')]),
2403
2406
  ).toEqual([['≤4 bp ins', 1, true]])