jbrowse-plugin-graphgenomeviewer 4.0.11 → 4.0.13

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 (62) hide show
  1. package/README.md +22 -4
  2. package/dist/chunks/GbzBaseSyntenyAdapter-Y5NBXK7P.js +8 -0
  3. package/dist/chunks/{GbzBaseSyntenyAdapter-URQ4Z2JB.js.map → GbzBaseSyntenyAdapter-Y5NBXK7P.js.map} +4 -4
  4. package/dist/chunks/{GraphGenomeView-44GJL4RA.js → GraphGenomeView-TTQEJJQD.js} +2 -2
  5. package/dist/chunks/{GraphGenomeView-44GJL4RA.js.map → GraphGenomeView-TTQEJJQD.js.map} +1 -1
  6. package/dist/chunks/GraphTrackSettingsDialog-3A2PKZRI.js +4 -0
  7. package/dist/chunks/{GraphTrackSettingsDialog-LZITZ3VD.js.map → GraphTrackSettingsDialog-3A2PKZRI.js.map} +1 -1
  8. package/dist/chunks/LinearGraphDisplay-MJ5XZFE5.js +2 -0
  9. package/dist/chunks/{LinearGraphDisplay-BWBXVSEQ.js.map → LinearGraphDisplay-MJ5XZFE5.js.map} +1 -1
  10. package/dist/chunks/{MinigraphBubbleAdapter-QTXGK7DW.js → MinigraphBubbleAdapter-APC7JYOM.js} +2 -2
  11. package/dist/chunks/{RgfaTabixAdapter-O3ERR2GY.js → RgfaTabixAdapter-4E365PXO.js} +2 -2
  12. package/dist/chunks/{chunk-LGGAXAS6.js → chunk-5DP7IXD3.js} +2 -2
  13. package/dist/chunks/chunk-CJ56O2VG.js +2 -0
  14. package/dist/chunks/chunk-CJ56O2VG.js.map +7 -0
  15. package/dist/chunks/chunk-EFVAQ2KM.js +2 -0
  16. package/dist/chunks/chunk-EFVAQ2KM.js.map +7 -0
  17. package/dist/chunks/chunk-IFRDWMQ2.js +2 -0
  18. package/dist/chunks/chunk-IFRDWMQ2.js.map +7 -0
  19. package/dist/chunks/chunk-JAQ2RQAE.js +2 -0
  20. package/dist/chunks/{chunk-QRVREF7H.js.map → chunk-JAQ2RQAE.js.map} +4 -4
  21. package/dist/chunks/{chunk-WETM2CD7.js → chunk-LA7MQS33.js} +2 -2
  22. package/dist/chunks/chunk-M4YEAXSR.js +2 -0
  23. package/dist/chunks/chunk-M4YEAXSR.js.map +7 -0
  24. package/dist/chunks/chunk-XDOIZTY6.js +4 -0
  25. package/dist/chunks/chunk-XDOIZTY6.js.map +7 -0
  26. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js +2 -2
  27. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js.map +4 -4
  28. package/package.json +4 -4
  29. package/src/GbzBaseSyntenyAdapter/configSchema.ts +3 -4
  30. package/src/GraphGenomeView/components/GraphCanvas.tsx +4 -11
  31. package/src/GraphGenomeView/components/TubeMapOverlay.tsx +117 -4
  32. package/src/GraphGenomeView/components/WalkRowsOverlay.tsx +261 -44
  33. package/src/GraphGenomeView/components/referenceRampCss.test.ts +27 -0
  34. package/src/GraphGenomeView/components/referenceRampCss.ts +39 -0
  35. package/src/GraphGenomeView/genes/genes.test.ts +9 -2
  36. package/src/GraphGenomeView/host.test.ts +25 -2
  37. package/src/GraphGenomeView/host.ts +9 -7
  38. package/src/GraphGenomeView/model.test.ts +383 -4
  39. package/src/GraphGenomeView/model.ts +413 -88
  40. package/src/GraphGenomeView/repeats/repeatFeatures.test.ts +75 -0
  41. package/src/GraphGenomeView/repeats/repeatFeatures.ts +156 -8
  42. package/src/GraphGenomeView/repeats/walkCalls.test.ts +18 -0
  43. package/src/GraphGenomeView/repeats/walkCalls.ts +35 -18
  44. package/src/GraphGenomeView/subgraphLoad.test.ts +1 -0
  45. package/src/LinearGraphDisplay/LinearGraphDisplay.test.ts +165 -6
  46. package/src/LinearGraphDisplay/model.ts +61 -1
  47. package/src/gaf/gafFile.test.ts +18 -0
  48. package/src/version.ts +1 -1
  49. package/dist/chunks/GbzBaseSyntenyAdapter-URQ4Z2JB.js +0 -8
  50. package/dist/chunks/GraphTrackSettingsDialog-LZITZ3VD.js +0 -4
  51. package/dist/chunks/LinearGraphDisplay-BWBXVSEQ.js +0 -2
  52. package/dist/chunks/chunk-ADEBM7AA.js +0 -2
  53. package/dist/chunks/chunk-ADEBM7AA.js.map +0 -7
  54. package/dist/chunks/chunk-MWSRAF5B.js +0 -4
  55. package/dist/chunks/chunk-MWSRAF5B.js.map +0 -7
  56. package/dist/chunks/chunk-QRVREF7H.js +0 -2
  57. package/dist/chunks/chunk-ZYQJBNS5.js +0 -2
  58. package/dist/chunks/chunk-ZYQJBNS5.js.map +0 -7
  59. /package/dist/chunks/{MinigraphBubbleAdapter-QTXGK7DW.js.map → MinigraphBubbleAdapter-APC7JYOM.js.map} +0 -0
  60. /package/dist/chunks/{RgfaTabixAdapter-O3ERR2GY.js.map → RgfaTabixAdapter-4E365PXO.js.map} +0 -0
  61. /package/dist/chunks/{chunk-LGGAXAS6.js.map → chunk-5DP7IXD3.js.map} +0 -0
  62. /package/dist/chunks/{chunk-WETM2CD7.js.map → chunk-LA7MQS33.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jbrowse-plugin-graphgenomeviewer",
3
- "version": "4.0.11",
3
+ "version": "4.0.13",
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": {
@@ -26,8 +26,8 @@
26
26
  "private": false,
27
27
  "dependencies": {
28
28
  "@gmod/bgzf-filehandle": "^6.7.0",
29
- "@gmod/gbz-base": "^2.8.0",
30
- "@gmod/tabix": "^3.8.3",
29
+ "@gmod/gbz-base": "^3.0.0",
30
+ "@gmod/tabix": "^3.9.0",
31
31
  "@gmod/tubemap-core": "^0.1.0",
32
32
  "@jbrowse/cigar-utils": "5.0.0-beta.9",
33
33
  "@jbrowse/core": "5.0.0-beta.9",
@@ -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.3"
79
+ "@jbrowse/bandage-core": "0.1.8"
80
80
  },
81
81
  "peerDependencies": {
82
82
  "react": ">=18.0.0",
@@ -13,10 +13,9 @@ import type { Instance } from '@jbrowse/mobx-state-tree'
13
13
  * graph's reference path, every haplotype's walk through the nodes it covers
14
14
  * is recovered and named, and each haplotype becomes one alignment record with
15
15
  * a CIGAR, so the graph is read through HTTP range requests with no offline
16
- * conversion. The database has to carry the `HaplotypeSamples` and
17
- * `HaplotypeLengths` side tables that `gbz-haplotype-index` (shipped with
18
- * `@gmod/gbz-base`) writes into an upstream-built database; without them the
19
- * graph cannot say which haplotype a walk belongs to.
16
+ * conversion. Naming the walks takes the companion haplotype index that
17
+ * `gbz-haplotype-index` writes; without it the graph cannot say which
18
+ * haplotype a walk belongs to.
20
19
  *
21
20
  * Only the fine tier exists: a whole-chromosome view stays on a PIF's coarse
22
21
  * tier. A query on a haplotype lane answers nothing, so the multi-way display
@@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from 'react'
2
2
 
3
3
  import { formatBp } from '@jbrowse/bandage-core/graphLabels'
4
4
  import { LEGEND_INSET_PX } from '@jbrowse/bandage-core/labelLayout'
5
- import { REFERENCE_RAMP_MAX_HUE } from '@jbrowse/bandage-core/renderer/GeometryBuilder'
6
5
  import {
7
6
  findHoveredEdge,
8
7
  findHoveredNode,
@@ -19,8 +18,9 @@ import BubbleOverlay from './BubbleOverlay'
19
18
  import GenePins from './GenePins'
20
19
  import GraphToolbar from './GraphToolbar'
21
20
  import LabelLayer from './LabelLayer'
22
- import TubeMapOverlay from './TubeMapOverlay'
21
+ import TubeMapOverlay, { TubeMapLegend } from './TubeMapOverlay'
23
22
  import WalkRowsOverlay, { WalkRowsLegend } from './WalkRowsOverlay'
23
+ import { RAMP_GRADIENT_CSS } from './referenceRampCss'
24
24
  import { locLabel, nodeOwnLocation } from '../../launchFromGraph/contributors'
25
25
  import { nodeLaunchMenuItems } from '../../launchFromGraph/graphMenuItems'
26
26
  import { createGraphRenderer } from '../renderer/GraphRenderer'
@@ -203,19 +203,11 @@ const WalkReadout = observer(function WalkReadout({
203
203
  // over. Nothing on screen used to say that red-to-magenta means left-to-right of
204
204
  // the cut window, so two tutorials carried that sentence in prose and a reader
205
205
  // arriving at a figure had no way to know it at all.
206
- //
207
- // The gradient is built from the same three numbers `getNodeColor` paints with
208
- // (hue 0 to REFERENCE_RAMP_MAX_HUE at 70%/50%), rather than from a picked pair
209
- // of hex stops, so it cannot come to describe a ramp the drawing stopped using.
210
206
  const rampStripStyle = {
211
207
  minWidth: 90,
212
208
  height: 8,
213
209
  borderRadius: 2,
214
- background: `linear-gradient(to right, ${Array.from(
215
- { length: 7 },
216
- (_, i) =>
217
- `hsl(${(i / 6) * REFERENCE_RAMP_MAX_HUE}, 70%, 50%) ${(i / 6) * 100}%`,
218
- ).join(', ')})`,
210
+ background: RAMP_GRADIENT_CSS,
219
211
  }
220
212
 
221
213
  const rampEndsStyle = {
@@ -302,6 +294,7 @@ const Legends = observer(function Legends({
302
294
  <div ref={ref} style={legendStackStyle}>
303
295
  <ReferenceRampLegend model={model} />
304
296
  <PathLegend model={model} />
297
+ <TubeMapLegend model={model} />
305
298
  <WalkRowsLegend model={model} />
306
299
  <WalkReadout model={model} />
307
300
  </div>
@@ -1,6 +1,19 @@
1
1
  import { useEffect, useRef } from 'react'
2
2
 
3
+ 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'
10
+ import { drawTubeMapConnectors } from '@jbrowse/bandage-core/tubeMap/connectors'
11
+ import {
12
+ deviationInk,
13
+ drawDeviationMarks,
14
+ } from '@jbrowse/bandage-core/tubeMap/deviations'
3
15
  import { drawTubeMap } from '@jbrowse/bandage-core/tubeMap/draw'
16
+ import { drawTubeMapGenes } from '@jbrowse/bandage-core/tubeMap/genes'
4
17
  import { getDpr } from '@jbrowse/render-core/canvas2dUtils'
5
18
  import { autorun } from 'mobx'
6
19
  import { observer } from 'mobx-react'
@@ -16,8 +29,11 @@ const canvasStyle = {
16
29
  }
17
30
 
18
31
  // The tube map's ink, over the canvas, which draws nothing under a tube map
19
- // layout. Repainted by an autorun on every change of transform, so a pan in
20
- // the linear view above moves the tubes in the same frame as its other tracks.
32
+ // layout. Above the tubes go the session's genes in a view of its own, or in
33
+ // a linear view the bands tying each reference box to its bp there; under
34
+ // them a reference ruler, unless the linear view's is already the axis.
35
+ // Repainted by an autorun on every change of transform, so a pan in the linear
36
+ // view above moves the tubes in the same frame as its other tracks.
21
37
  const TubeMapOverlay = observer(function TubeMapOverlay({
22
38
  model,
23
39
  }: {
@@ -40,12 +56,31 @@ const TubeMapOverlay = observer(function TubeMapOverlay({
40
56
  canvas.height = Math.round(height * dpr)
41
57
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
42
58
  ctx.clearRect(0, 0, width, height)
43
- drawTubeMap(ctx, picture, {
59
+ const tubeFrame = {
44
60
  ...frame,
45
61
  width,
46
62
  highlightNode: model.hoveredNode ?? model.selectedNode,
47
63
  darkMode: model.darkMode,
48
- })
64
+ }
65
+ drawTubeMap(ctx, picture, tubeFrame)
66
+ drawDeviationMarks(ctx, model.tubeMapDeviations, tubeFrame)
67
+ drawTubeMapConnectors(
68
+ ctx,
69
+ model.tubeMapConnectors,
70
+ model.connectorZoneBottom,
71
+ tubeFrame,
72
+ )
73
+ const { bounds } = picture
74
+ drawTubeMapGenes(
75
+ ctx,
76
+ model.tubeMapGenes,
77
+ tubeFrame,
78
+ frame.y(bounds.minY) - 2,
79
+ )
80
+ const boxes = model.tubeMapRulerBoxes
81
+ if (boxes) {
82
+ drawTubeMapRuler(ctx, boxes, tubeFrame, frame.y(bounds.maxY) + 4)
83
+ }
49
84
  }
50
85
  }),
51
86
  [model],
@@ -67,3 +102,81 @@ const TubeMapOverlay = observer(function TubeMapOverlay({
67
102
  })
68
103
 
69
104
  export default TubeMapOverlay
105
+
106
+ const legendBoxStyle = {
107
+ background: 'rgba(255,255,255,0.82)',
108
+ padding: '4px 6px',
109
+ borderRadius: 3,
110
+ fontSize: 11,
111
+ lineHeight: '15px',
112
+ whiteSpace: 'nowrap' as const,
113
+ }
114
+ const legendRowStyle = { display: 'flex', alignItems: 'center', gap: 5 }
115
+ const SWATCH_PX = 18
116
+
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}`)
129
+ return (
130
+ <svg width={SWATCH_PX} height={mid * 2} style={{ flex: 'none' }}>
131
+ <polyline
132
+ points={points.join(' ')}
133
+ fill="none"
134
+ stroke={rulerInk()}
135
+ strokeWidth={1}
136
+ />
137
+ </svg>
138
+ )
139
+ }
140
+
141
+ const tubeSwatchStyle = {
142
+ width: SWATCH_PX,
143
+ height: 7,
144
+ flex: 'none',
145
+ display: 'flex',
146
+ justifyContent: 'center',
147
+ backgroundColor: '#b8b8c0',
148
+ }
149
+
150
+ function TickSwatch() {
151
+ return (
152
+ <div style={tubeSwatchStyle}>
153
+ <div style={{ width: 2, backgroundColor: deviationInk() }} />
154
+ </div>
155
+ )
156
+ }
157
+
158
+ // The two marks a tube map draws that nothing else on screen explains, each
159
+ // listed only while it is drawn
160
+ export const TubeMapLegend = observer(function TubeMapLegend({
161
+ model,
162
+ }: {
163
+ model: GraphPaneModel
164
+ }) {
165
+ const { squeezed, foldBp } = model.tubeMapKeys
166
+ return squeezed || foldBp !== undefined ? (
167
+ <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}
180
+ </div>
181
+ ) : null
182
+ })
@@ -1,20 +1,34 @@
1
+ import { useId } from 'react'
2
+
1
3
  import { ROW_HEIGHT_PX } from '@jbrowse/bandage-core/layout/rowSpacing'
2
4
  import { LABEL_CHAR_PX } from '@jbrowse/bandage-core/overlayLabels'
5
+ import { REFERENCE_RAMP_ALT_CSS } from '@jbrowse/bandage-core/renderer/GeometryBuilder'
3
6
  import { observer } from 'mobx-react'
4
7
 
8
+ import { RAMP_GRADIENT_CSS, rampHueCss, rampStops } from './referenceRampCss'
5
9
  import { CALL_TOLERANCE } from '../repeats/walkCalls'
6
10
 
7
11
  import type { GraphPaneModel } from '../model'
12
+ import type { RepeatSequence, RepeatUnit } from '../repeats/repeatFeatures'
13
+ import type { WalkCall } from '../repeats/walkCalls'
14
+ import type { WalkRun } from '@jbrowse/bandage-core/layout/walkRows'
8
15
 
9
16
  // The walk-rows layout's bars: one per haplotype walk under the reference line
10
17
  // the canvas draws, each on its own bp axis from the window's left edge. Blue
11
- // is sequence the reference walk also carries, purple is sequence it does not,
12
- // and the readout at the end of a bar is what it carries against the
18
+ // marks sequence shared with the reference walk and purple sequence only the
19
+ // haplotypes carry. Under the reference-position ramp, shared sequence takes
20
+ // the hue of the reference it is threaded through and haplotype-only sequence
21
+ // the charcoal the ramp reserves for rGFA's off-reference ranks. In a tandem
22
+ // array the threading is the aligner's pick among near-identical copies, so
23
+ // the hue there says which reference copy the graph used, not which one a copy
24
+ // resembles. The readout at the end of a bar is what it carries against the
13
25
  // reference, which for a repeat array is the expansion.
14
26
  //
15
- // A tick is the genotyped allele length paired with THAT walk, and the walk
16
- // rows pair them — see repeats/walkCalls.ts, which also holds the threshold
17
- // this reads a red readout off.
27
+ // A repeat record that states each allele's runs (VCF 4.5 <CNV:TR> RN, RUS,
28
+ // RUC, RB) paints every bar by unit instead: the allele paired with THAT walk,
29
+ // one colour per unit the record names. A record stating only a length marks
30
+ // it with a tick. The walk rows pair them — see repeats/walkCalls.ts, which
31
+ // also holds the threshold this reads a red readout off.
18
32
 
19
33
  const svgStyle = {
20
34
  position: 'absolute' as const,
@@ -32,6 +46,29 @@ const DISAGREES = '#c62828'
32
46
  const OFF_REFERENCE = '#8e3fbf'
33
47
  const BAR_PX = 12
34
48
 
49
+ // Tableau 10, whose leading hues stay apart in lightness as well as hue
50
+ const UNIT_COLORS = [
51
+ '#4e79a7',
52
+ '#f28e2b',
53
+ '#59a14f',
54
+ '#e15759',
55
+ '#76b7b2',
56
+ '#edc948',
57
+ '#b07aa1',
58
+ '#ff9da7',
59
+ '#9c755f',
60
+ '#bab0ac',
61
+ ]
62
+ const NO_ALLELE = '#d0d0d0'
63
+ // the canvas's grey scheme, for the backbone past the array when no ramp is
64
+ // drawn anywhere else
65
+ const BACKBONE = 'rgb(160, 160, 160)'
66
+
67
+ function unitColor(units: RepeatUnit[], unit: string) {
68
+ const i = units.findIndex(u => u.unit === unit)
69
+ return i < 0 ? NO_ALLELE : UNIT_COLORS[i % UNIT_COLORS.length]!
70
+ }
71
+
35
72
  const legendBoxStyle = {
36
73
  background: 'rgba(255,255,255,0.82)',
37
74
  padding: '4px 6px',
@@ -69,17 +106,58 @@ export const WalkRowsLegend = observer(function WalkRowsLegend({
69
106
  if (!bars) {
70
107
  return null
71
108
  }
72
- const calls = bars.rows.flatMap(row => row.call ?? [])
109
+ const ramp = model.referenceRampDomain
110
+ const units = model.walkRowUnits
111
+ const drawn = [bars.reference, ...bars.rows]
112
+ const calls = drawn.flatMap(row =>
113
+ row.call && !row.call.sequences ? row.call : [],
114
+ )
73
115
  return (
74
116
  <div style={legendBoxStyle} data-testid="graph-walk-rows-legend">
75
- <div style={legendRowStyle}>
76
- <div style={{ ...swatchStyle, backgroundColor: ON_REFERENCE }} />
77
- <span>sequence {bars.reference.label} also carries</span>
78
- </div>
79
- <div style={legendRowStyle}>
80
- <div style={{ ...swatchStyle, backgroundColor: OFF_REFERENCE }} />
81
- <span>sequence it does not</span>
82
- </div>
117
+ {units ? (
118
+ <>
119
+ {units.map((unit, i) => (
120
+ <div key={unit.unit} style={legendRowStyle}>
121
+ <div
122
+ style={{
123
+ ...swatchStyle,
124
+ backgroundColor: unitColor(units, unit.unit),
125
+ }}
126
+ />
127
+ <span>
128
+ unit {i + 1} · {unit.unitLength.toLocaleString()} bp
129
+ </span>
130
+ </div>
131
+ ))}
132
+ {drawn.some(row => !row.call?.sequences) ? (
133
+ <div style={legendRowStyle}>
134
+ <div style={{ ...swatchStyle, backgroundColor: NO_ALLELE }} />
135
+ <span>no allele in the repeat track</span>
136
+ </div>
137
+ ) : null}
138
+ </>
139
+ ) : (
140
+ <>
141
+ <div style={legendRowStyle}>
142
+ <div
143
+ style={{
144
+ ...swatchStyle,
145
+ background: ramp ? RAMP_GRADIENT_CSS : ON_REFERENCE,
146
+ }}
147
+ />
148
+ <span>shared with {bars.reference.label}</span>
149
+ </div>
150
+ <div style={legendRowStyle}>
151
+ <div
152
+ style={{
153
+ ...swatchStyle,
154
+ backgroundColor: ramp ? REFERENCE_RAMP_ALT_CSS : OFF_REFERENCE,
155
+ }}
156
+ />
157
+ <span>carried by haplotypes only</span>
158
+ </div>
159
+ </>
160
+ )}
83
161
  {calls.some(call => call.spanningReads !== 0) ? (
84
162
  <div style={legendRowStyle}>
85
163
  <TickSwatch color={CALL_TICK} />
@@ -112,16 +190,27 @@ function units(bp: number, unit: number | undefined) {
112
190
  return unit ? ` ≈ ${Math.round(bp / unit)} units` : ''
113
191
  }
114
192
 
193
+ // A call stating its runs counts its copies outright; otherwise the bar's
194
+ // length in units is the estimate.
195
+ function copies(bp: number, unit: number | undefined, call?: WalkCall) {
196
+ if (!call?.sequences) {
197
+ return units(bp, unit)
198
+ }
199
+ const n = call.sequences.reduce((sum, run) => sum + run.count, 0)
200
+ return ` · ${Number.isInteger(n) ? n : n.toFixed(1)} copies`
201
+ }
202
+
115
203
  function readout(
116
- bp: number,
204
+ row: { bp: number; complete: boolean; call?: WalkCall },
117
205
  referenceBp: number,
118
- complete: boolean,
119
206
  unit: number | undefined,
120
207
  ) {
208
+ const { bp, complete, call } = row
121
209
  const delta = bp - referenceBp
122
210
  const against =
123
211
  delta === 0 ? '' : ` (${delta > 0 ? '+' : '−'}${kb(Math.abs(delta))})`
124
- return `${kb(bp)}${units(bp, unit)}${against}${complete ? '' : ' · partial walk'}`
212
+ const called = call && !call.sequences ? calledReadout(call) : ''
213
+ return `${kb(bp)}${copies(bp, unit, call)}${against}${complete ? '' : ' · partial walk'}${called}`
125
214
  }
126
215
 
127
216
  function calledReadout(call: { bp: number; spanningReads?: number }) {
@@ -129,6 +218,26 @@ function calledReadout(call: { bp: number; spanningReads?: number }) {
129
218
  return ` · called ${kb(call.bp)}${unbacked}`
130
219
  }
131
220
 
221
+ // Each run's extent from the bar's left end, and where each of its copies
222
+ // starts: RUB's lengths where the record gives them, else the unit's.
223
+ function runsOf(sequences: RepeatSequence[]) {
224
+ let at = 0
225
+ return sequences.map(run => {
226
+ const start = at
227
+ const lengths =
228
+ run.copyBp ??
229
+ Array.from({ length: Math.ceil(run.count) }, () => run.unitLength)
230
+ const copyStarts: number[] = []
231
+ let copy = start
232
+ for (const bp of lengths) {
233
+ copyStarts.push(copy)
234
+ copy += bp
235
+ }
236
+ at += run.bp
237
+ return { start, bp: run.bp, unit: run.unit, copyStarts }
238
+ })
239
+ }
240
+
132
241
  // One separator per unit along a bar, so copies are countable, dropped when a
133
242
  // unit is under a few px. The reference bar is the canvas's, so it gets none.
134
243
  const MIN_TILE_PX = 3
@@ -144,12 +253,46 @@ function tileSeparators(bp: number, unit: number, X: (bp: number) => number) {
144
253
  return xs
145
254
  }
146
255
 
256
+ // A shared run covers its reference contiguously, so a gradient along it
257
+ // paints the hue of every base.
258
+ function runFill(
259
+ run: WalkRun,
260
+ ramp: { start: number; end: number } | undefined,
261
+ gradientId: string,
262
+ ) {
263
+ if (!ramp) {
264
+ return { fill: run.onReference ? ON_REFERENCE : OFF_REFERENCE }
265
+ }
266
+ if (run.referenceStart === undefined) {
267
+ return { fill: REFERENCE_RAMP_ALT_CSS }
268
+ }
269
+ const stops = rampStops({ ...run, start: run.referenceStart }, ramp)
270
+ if (stops.length === 1) {
271
+ return { fill: rampHueCss(stops[0]!) }
272
+ }
273
+ return {
274
+ fill: `url(#${gradientId})`,
275
+ gradient: (
276
+ <linearGradient id={gradientId}>
277
+ {stops.map((hue, i) => (
278
+ <stop
279
+ key={i}
280
+ offset={i / (stops.length - 1)}
281
+ stopColor={rampHueCss(hue)}
282
+ />
283
+ ))}
284
+ </linearGradient>
285
+ ),
286
+ }
287
+ }
288
+
147
289
  const WalkRowsOverlay = observer(function WalkRowsOverlay({
148
290
  model,
149
291
  }: {
150
292
  model: GraphPaneModel
151
293
  }) {
152
- const { walkRowBars } = model
294
+ const idPrefix = useId().replace(/[^\w-]/g, '')
295
+ const { walkRowBars, referenceRampDomain: ramp } = model
153
296
  if (!walkRowBars) {
154
297
  return null
155
298
  }
@@ -164,8 +307,10 @@ const WalkRowsOverlay = observer(function WalkRowsOverlay({
164
307
  const X = (bp: number) => bp * scaleX + translateX
165
308
  const Y = (row: number) => row * ROW_HEIGHT_PX * scaleY + translateY
166
309
  const { origin, unit, reference, rows } = walkRowBars
310
+ const units = model.walkRowUnits
167
311
  // The backbone the canvas draws spans the cut window, which reaches past a
168
312
  // selected array on both sides, so the reference readout sits after it.
313
+ const backboneStart = Math.min(origin, model.graphRegion?.start ?? origin)
169
314
  const backboneEnd = Math.max(
170
315
  origin + reference.bp,
171
316
  model.graphRegion?.end ?? 0,
@@ -190,6 +335,62 @@ const WalkRowsOverlay = observer(function WalkRowsOverlay({
190
335
  </text>
191
336
  )
192
337
  }
338
+ const separators = (starts: number[], bp: number, y: number) =>
339
+ starts
340
+ .filter(k => k > 0 && k < bp)
341
+ .map(k => (
342
+ <line
343
+ key={k}
344
+ x1={X(origin + k)}
345
+ x2={X(origin + k)}
346
+ y1={y - BAR_PX / 2}
347
+ y2={y + BAR_PX / 2}
348
+ stroke="white"
349
+ strokeWidth={1}
350
+ />
351
+ ))
352
+ const rect = (start: number, bp: number, y: number, fill: string) => (
353
+ <rect
354
+ key={start}
355
+ x={X(origin + start)}
356
+ y={y - BAR_PX / 2}
357
+ width={Math.max(1, bp * scaleX)}
358
+ height={BAR_PX}
359
+ fill={fill}
360
+ />
361
+ )
362
+ // The paired allele's runs, cut at the end of the walk, over grey where it
363
+ // states none.
364
+ const unitBar = (
365
+ row: { bp: number; call?: WalkCall },
366
+ y: number,
367
+ painted: RepeatUnit[],
368
+ ) => {
369
+ const runs = row.call?.sequences ? runsOf(row.call.sequences) : []
370
+ const tiled = unit !== undefined && unit * scaleX >= MIN_TILE_PX
371
+ return (
372
+ <>
373
+ {rect(0, row.bp, y, NO_ALLELE)}
374
+ {runs
375
+ .filter(run => run.start < row.bp)
376
+ .map(run =>
377
+ rect(
378
+ run.start,
379
+ Math.min(run.bp, row.bp - run.start),
380
+ y,
381
+ unitColor(painted, run.unit),
382
+ ),
383
+ )}
384
+ {tiled
385
+ ? separators(
386
+ runs.flatMap(run => run.copyStarts),
387
+ row.bp,
388
+ y,
389
+ )
390
+ : null}
391
+ </>
392
+ )
393
+ }
193
394
  return (
194
395
  <svg
195
396
  style={svgStyle}
@@ -197,8 +398,21 @@ const WalkRowsOverlay = observer(function WalkRowsOverlay({
197
398
  height={canvasHeight}
198
399
  data-testid="graph-walk-rows"
199
400
  >
401
+ {units ? (
402
+ <>
403
+ <rect
404
+ x={X(backboneStart) - BAR_PX / 2}
405
+ y={Y(0) - BAR_PX / 2}
406
+ width={(backboneEnd - backboneStart) * scaleX + BAR_PX}
407
+ height={BAR_PX}
408
+ rx={BAR_PX / 2}
409
+ fill={BACKBONE}
410
+ />
411
+ {unitBar(reference, Y(0), units)}
412
+ </>
413
+ ) : null}
200
414
  {label(
201
- `${kb(reference.bp)}${units(reference.bp, unit)}`,
415
+ `${kb(reference.bp)}${copies(reference.bp, unit, reference.call)}`,
202
416
  backboneEnd,
203
417
  Y(0),
204
418
  )}
@@ -210,30 +424,33 @@ const WalkRowsOverlay = observer(function WalkRowsOverlay({
210
424
  const { call } = row
211
425
  return (
212
426
  <g key={row.name} data-testid="graph-walk-row">
213
- {row.runs.map(run => (
214
- <rect
215
- key={run.start}
216
- x={X(origin + run.start)}
217
- y={y - BAR_PX / 2}
218
- width={Math.max(1, run.bp * scaleX)}
219
- height={BAR_PX}
220
- fill={run.onReference ? ON_REFERENCE : OFF_REFERENCE}
221
- />
222
- ))}
223
- {unit
224
- ? tileSeparators(row.bp, unit, bp => bp * scaleX).map(k => (
225
- <line
226
- key={k}
227
- x1={X(origin + k)}
228
- x2={X(origin + k)}
229
- y1={y - BAR_PX / 2}
230
- y2={y + BAR_PX / 2}
231
- stroke="white"
232
- strokeWidth={1}
233
- />
234
- ))
235
- : null}
236
- {call ? (
427
+ {units ? (
428
+ unitBar(row, y, units)
429
+ ) : (
430
+ <>
431
+ {row.runs.map(run => {
432
+ const { fill, gradient } = runFill(
433
+ run,
434
+ ramp,
435
+ `${idPrefix}-${i}-${run.start}`,
436
+ )
437
+ return (
438
+ <g key={run.start}>
439
+ {gradient}
440
+ {rect(run.start, run.bp, y, fill)}
441
+ </g>
442
+ )
443
+ })}
444
+ {unit
445
+ ? separators(
446
+ tileSeparators(row.bp, unit, bp => bp * scaleX),
447
+ row.bp,
448
+ y,
449
+ )
450
+ : null}
451
+ </>
452
+ )}
453
+ {call && !call.sequences ? (
237
454
  <rect
238
455
  data-testid="graph-walk-call"
239
456
  x={X(origin + call.bp) - 1}
@@ -244,7 +461,7 @@ const WalkRowsOverlay = observer(function WalkRowsOverlay({
244
461
  />
245
462
  ) : null}
246
463
  {label(
247
- `${readout(row.bp, reference.bp, row.complete, unit)}${call ? calledReadout(call) : ''}`,
464
+ readout(row, reference.bp, unit),
248
465
  origin + row.bp,
249
466
  y,
250
467
  call?.agrees === false ? DISAGREES : undefined,
@@ -0,0 +1,27 @@
1
+ import { rampHue, rampStops } from './referenceRampCss'
2
+
3
+ const domain = { start: 1000, end: 2000 }
4
+
5
+ test('the hue runs 0 to 300 across the domain and holds past its ends', () => {
6
+ expect(rampHue(1000, domain)).toBe(0)
7
+ expect(rampHue(1500, domain)).toBe(150)
8
+ expect(rampHue(2000, domain)).toBe(300)
9
+ expect(rampHue(0, domain)).toBe(0)
10
+ expect(rampHue(9000, domain)).toBe(300)
11
+ })
12
+
13
+ test('a span under a degree of hue reads as one flat hue', () => {
14
+ expect(rampStops({ start: 1500, bp: 2 }, domain)).toEqual([150.3])
15
+ })
16
+
17
+ test('stops step at most 30 degrees, from the far end when reversed', () => {
18
+ const forward = rampStops({ start: 1000, bp: 500 }, domain)
19
+ expect(forward).toEqual([0, 30, 60, 90, 120, 150])
20
+ expect(rampStops({ start: 1000, bp: 500, reversed: true }, domain)).toEqual(
21
+ [...forward].reverse(),
22
+ )
23
+ })
24
+
25
+ test('a span reaching past the domain holds its last hue there', () => {
26
+ expect(rampStops({ start: 1800, bp: 400 }, domain)).toEqual([240, 300, 300])
27
+ })