react-msaview 5.9.0 → 5.10.0

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 (71) hide show
  1. package/bundle/index.js +76 -76
  2. package/bundle/index.js.map +3 -3
  3. package/dist/components/ConservationTrack.js +21 -16
  4. package/dist/components/ConservationTrack.js.map +1 -1
  5. package/dist/components/MSAView.js +17 -23
  6. package/dist/components/MSAView.js.map +1 -1
  7. package/dist/components/TextTrack.js +26 -21
  8. package/dist/components/TextTrack.js.map +1 -1
  9. package/dist/components/header/settingsMenuItems.js +39 -72
  10. package/dist/components/header/settingsMenuItems.js.map +1 -1
  11. package/dist/components/msa/MSACanvasBlock.js +19 -16
  12. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  13. package/dist/components/msa/MSAMouseoverCanvas.js +13 -6
  14. package/dist/components/msa/MSAMouseoverCanvas.js.map +1 -1
  15. package/dist/components/msa/renderBoxFeatureCanvasBlock.js +9 -6
  16. package/dist/components/msa/renderBoxFeatureCanvasBlock.js.map +1 -1
  17. package/dist/components/msa/renderMSAMouseover.js +31 -31
  18. package/dist/components/msa/renderMSAMouseover.js.map +1 -1
  19. package/dist/components/overlayColors.d.ts +2 -0
  20. package/dist/components/overlayColors.js +5 -0
  21. package/dist/components/overlayColors.js.map +1 -1
  22. package/dist/components/tree/TreeCanvas.js +29 -24
  23. package/dist/components/tree/TreeCanvas.js.map +1 -1
  24. package/dist/components/tree/TreeCanvasBlock.js +19 -12
  25. package/dist/components/tree/TreeCanvasBlock.js.map +1 -1
  26. package/dist/components/tree/renderTreeCanvas.d.ts +0 -38
  27. package/dist/components/tree/renderTreeCanvas.js +17 -17
  28. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  29. package/dist/model.js +82 -113
  30. package/dist/model.js.map +1 -1
  31. package/dist/neighborJoining.js +12 -19
  32. package/dist/neighborJoining.js.map +1 -1
  33. package/dist/parseAsn1.js +32 -43
  34. package/dist/parseAsn1.js.map +1 -1
  35. package/dist/renderToSvg.js +3 -0
  36. package/dist/renderToSvg.js.map +1 -1
  37. package/dist/seqPosToGlobalCol.d.ts +6 -19
  38. package/dist/seqPosToGlobalCol.js +6 -33
  39. package/dist/seqPosToGlobalCol.js.map +1 -1
  40. package/dist/useCanvasAutorun.d.ts +23 -1
  41. package/dist/useCanvasAutorun.js +20 -8
  42. package/dist/useCanvasAutorun.js.map +1 -1
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/dist/version.js.map +1 -1
  46. package/package.json +3 -3
  47. package/src/components/ConservationTrack.tsx +7 -5
  48. package/src/components/MSAView.tsx +24 -30
  49. package/src/components/TextTrack.tsx +7 -12
  50. package/src/components/header/settingsMenuItems.ts +40 -72
  51. package/src/components/msa/MSACanvasBlock.tsx +16 -21
  52. package/src/components/msa/MSAMouseoverCanvas.tsx +10 -6
  53. package/src/components/msa/renderBoxFeatureCanvasBlock.test.ts +51 -0
  54. package/src/components/msa/renderBoxFeatureCanvasBlock.ts +13 -8
  55. package/src/components/msa/renderMSAMouseover.ts +37 -32
  56. package/src/components/overlayColors.ts +6 -0
  57. package/src/components/tree/TreeCanvas.tsx +6 -4
  58. package/src/components/tree/TreeCanvasBlock.tsx +11 -12
  59. package/src/components/tree/renderTreeCanvas.ts +20 -17
  60. package/src/model.ts +97 -130
  61. package/src/modelFilehandleLoaders.test.ts +137 -0
  62. package/src/neighborJoining.test.ts +12 -0
  63. package/src/neighborJoining.ts +12 -20
  64. package/src/parseAsn1.ts +32 -40
  65. package/src/{renderDomainLegendSvg.test.tsx → renderDomainsSvg.test.tsx} +44 -20
  66. package/src/renderToSvg.tsx +3 -0
  67. package/src/seqPosToGlobalCol.test.ts +39 -51
  68. package/src/seqPosToGlobalCol.ts +6 -41
  69. package/src/useCanvasAutorun.test.tsx +98 -0
  70. package/src/useCanvasAutorun.ts +30 -11
  71. package/src/version.ts +1 -1
@@ -0,0 +1,137 @@
1
+ // The reactive filehandle loaders in afterCreate: each fetches when its
2
+ // filehandle changes, and each guards against a stale response, since a user can
3
+ // pick a second file while the first is still downloading.
4
+ import { beforeEach, expect, test, vi } from 'vitest'
5
+
6
+ import MSAModelF from './model.ts'
7
+
8
+ import type * as FetchUtils from './fetchUtils.ts'
9
+
10
+ // uri -> settle functions for the fetch that autorun kicked off
11
+ const inFlight = new Map<
12
+ string,
13
+ { resolve: (text: string) => void; reject: (e: unknown) => void }
14
+ >()
15
+
16
+ vi.mock('@jbrowse/core/util/io', () => ({
17
+ openLocation: (loc: { uri: string }) => loc,
18
+ }))
19
+
20
+ vi.mock('./fetchUtils.ts', async importOriginal => ({
21
+ ...(await importOriginal<typeof FetchUtils>()),
22
+ fetchTextWithProgress: (loc: { uri: string }) =>
23
+ new Promise<string>((resolve, reject) => {
24
+ inFlight.set(loc.uri, { resolve, reject })
25
+ }),
26
+ }))
27
+
28
+ function uri(u: string) {
29
+ return { locationType: 'UriLocation' as const, uri: u }
30
+ }
31
+
32
+ function fasta(seq: string) {
33
+ return `>seq1\n${seq}`
34
+ }
35
+
36
+ // let the awaited fetch and the actions that follow it run
37
+ function flush() {
38
+ return new Promise(resolve => setTimeout(resolve, 0))
39
+ }
40
+
41
+ function makeModel() {
42
+ const model = MSAModelF().create({ type: 'MsaView' })
43
+ model.setWidth(800)
44
+ return model
45
+ }
46
+
47
+ beforeEach(() => {
48
+ inFlight.clear()
49
+ })
50
+
51
+ test('an msa filehandle loads into the model and clears its loading flag', async () => {
52
+ const model = makeModel()
53
+ model.setMSAFilehandle(uri('aln.fa'))
54
+ expect(model.loadingMSA).toBe(true)
55
+
56
+ inFlight.get('aln.fa')!.resolve(fasta('ACDE'))
57
+ await flush()
58
+
59
+ expect(model.data.msa).toBe(fasta('ACDE'))
60
+ expect(model.loadingMSA).toBe(false)
61
+ })
62
+
63
+ test('a slower earlier fetch never clobbers a newer one', async () => {
64
+ const model = makeModel()
65
+ model.setMSAFilehandle(uri('first.fa'))
66
+ model.setMSAFilehandle(uri('second.fa'))
67
+
68
+ // the newer fetch finishes first, then the abandoned one comes back
69
+ inFlight.get('second.fa')!.resolve(fasta('SECOND'))
70
+ await flush()
71
+ inFlight.get('first.fa')!.resolve(fasta('FIRST'))
72
+ await flush()
73
+
74
+ expect(model.data.msa).toBe(fasta('SECOND'))
75
+ expect(model.loadingMSA).toBe(false)
76
+ })
77
+
78
+ test('a stale failure does not raise an error over live data', async () => {
79
+ const model = makeModel()
80
+ model.setMSAFilehandle(uri('first.fa'))
81
+ model.setMSAFilehandle(uri('second.fa'))
82
+
83
+ inFlight.get('second.fa')!.resolve(fasta('SECOND'))
84
+ await flush()
85
+ inFlight.get('first.fa')!.reject(new Error('too slow'))
86
+ await flush()
87
+
88
+ expect(model.error).toBeUndefined()
89
+ expect(model.data.msa).toBe(fasta('SECOND'))
90
+ })
91
+
92
+ test('cancelling drops the filehandle so the view returns to the import form', async () => {
93
+ const model = makeModel()
94
+ model.setMSAFilehandle(uri('aln.fa'))
95
+
96
+ inFlight.get('aln.fa')!.reject(new DOMException('Aborted', 'AbortError'))
97
+ await flush()
98
+
99
+ expect(model.msaFilehandle).toBeUndefined()
100
+ expect(model.error).toBeUndefined()
101
+ expect(model.loadingMSA).toBe(false)
102
+ })
103
+
104
+ test('a failed fetch surfaces as an error', async () => {
105
+ const model = makeModel()
106
+ model.setMSAFilehandle(uri('aln.fa'))
107
+
108
+ inFlight.get('aln.fa')!.reject(new Error('404'))
109
+ await flush()
110
+
111
+ expect(model.error).toBeInstanceOf(Error)
112
+ expect(model.loadingMSA).toBe(false)
113
+ })
114
+
115
+ test('tree, treeMetadata and gff filehandles each load into their own field', async () => {
116
+ const model = MSAModelF().create({
117
+ type: 'MsaView',
118
+ treeFilehandle: uri('tree.nh'),
119
+ treeMetadataFilehandle: uri('meta.json'),
120
+ gffFilehandle: uri('domains.gff'),
121
+ })
122
+ model.setWidth(800)
123
+
124
+ inFlight.get('tree.nh')!.resolve('(a,b);')
125
+ inFlight.get('meta.json')!.resolve('{"a":{"genome":"human"}}')
126
+ inFlight
127
+ .get('domains.gff')!
128
+ .resolve(
129
+ '##gff-version 3\na\tPfam\tprotein_match\t1\t2\t.\t.\t.\tName=PF00069',
130
+ )
131
+ await flush()
132
+
133
+ expect(model.data.tree).toBe('(a,b);')
134
+ expect(model.treeMetadata.a?.genome).toBe('human')
135
+ expect(model.actuallyShowDomains).toBe(true)
136
+ expect(model.loadingTree).toBe(false)
137
+ })
@@ -149,4 +149,16 @@ describe('calculateNeighborJoiningTree', () => {
149
149
  expect(tree).toContain('similar2')
150
150
  expect(tree).toContain('different')
151
151
  })
152
+
153
+ test('handles ragged rows, treating a short row as gapped past its end', () => {
154
+ const rows: [string, string][] = [
155
+ ['full', 'MKAAYLSMFGKED'],
156
+ ['short', 'MKAAYL'],
157
+ ['other', 'WWWWWWWWWWWWW'],
158
+ ]
159
+ const tree = calculateNeighborJoiningTree(rows)
160
+
161
+ expect(tree).toMatch(/;$/)
162
+ expect(tree).toContain('short')
163
+ })
152
164
  })
@@ -53,18 +53,18 @@ function isGap(c: string) {
53
53
  }
54
54
 
55
55
  function computePairwiseDistance(seq1: string, seq2: string) {
56
- if (seq1.length !== seq2.length) {
57
- throw new Error('Sequences must have the same length (aligned)')
58
- }
59
-
60
- let matches = 0
61
- let mismatches = 0
56
+ // ragged input is real -- a3m and hand-edited fasta both produce rows shorter
57
+ // than the alignment -- so a row that stops early counts as gapped for the
58
+ // remainder, the same rule the model's `blanks` getter applies. Requiring
59
+ // equal lengths here instead made "build tree from MSA" throw on those files.
60
+ const len = Math.max(seq1.length, seq2.length)
61
+ let compared = 0
62
62
  let totalScore = 0
63
63
  let maxPossibleScore = 0
64
64
 
65
- for (let i = 0; i < seq1.length; i++) {
66
- const a = seq1[i]!
67
- const b = seq2[i]!
65
+ for (let i = 0; i < len; i++) {
66
+ const a = seq1[i] ?? '-'
67
+ const b = seq2[i] ?? '-'
68
68
 
69
69
  const aGap = isGap(a)
70
70
  const bGap = isGap(b)
@@ -72,24 +72,16 @@ function computePairwiseDistance(seq1: string, seq2: string) {
72
72
  continue
73
73
  }
74
74
 
75
+ compared++
75
76
  if (aGap || bGap) {
76
- mismatches++
77
77
  continue
78
78
  }
79
79
 
80
- const score = getBlosum62Score(a, b)
81
- totalScore += score
80
+ totalScore += getBlosum62Score(a, b)
82
81
  maxPossibleScore += Math.max(getBlosum62Score(a, a), getBlosum62Score(b, b))
83
-
84
- if (a.toUpperCase() === b.toUpperCase()) {
85
- matches++
86
- } else {
87
- mismatches++
88
- }
89
82
  }
90
83
 
91
- const total = matches + mismatches
92
- if (total === 0) {
84
+ if (compared === 0) {
93
85
  return 1
94
86
  }
95
87
 
package/src/parseAsn1.ts CHANGED
@@ -32,28 +32,38 @@ const remap: Record<string, string> = {
32
32
  'leaf-count': 'leafCount',
33
33
  }
34
34
 
35
+ // index just past the '}' closing the '{' at openIdx, or -1 when it never
36
+ // closes. Braces nest (a node holds features, which hold values), so finding the
37
+ // end of a block means counting depth rather than taking the next '}'.
38
+ function afterMatchingBrace(str: string, openIdx: number) {
39
+ let depth = 1
40
+ let pos = openIdx + 1
41
+ while (pos < str.length && depth > 0) {
42
+ if (str[pos] === '{') {
43
+ depth++
44
+ } else if (str[pos] === '}') {
45
+ depth--
46
+ }
47
+ pos++
48
+ }
49
+ return depth === 0 ? pos : -1
50
+ }
51
+
52
+ // the contents of each top-level {...} block in str, siblings only
35
53
  function extractBracedBlocks(str: string): string[] {
36
54
  const blocks: string[] = []
37
55
  let pos = 0
38
56
  while (pos < str.length) {
39
- pos = str.indexOf('{', pos)
40
- if (pos === -1) {
57
+ const openIdx = str.indexOf('{', pos)
58
+ if (openIdx === -1) {
41
59
  break
42
60
  }
43
- let depth = 1
44
- const start = pos + 1
45
- pos++
46
- while (pos < str.length && depth > 0) {
47
- if (str[pos] === '{') {
48
- depth++
49
- } else if (str[pos] === '}') {
50
- depth--
51
- }
52
- pos++
53
- }
54
- if (depth === 0) {
55
- blocks.push(str.slice(start, pos - 1).trim())
61
+ const end = afterMatchingBrace(str, openIdx)
62
+ if (end === -1) {
63
+ break
56
64
  }
65
+ blocks.push(str.slice(openIdx + 1, end - 1).trim())
66
+ pos = end
57
67
  }
58
68
  return blocks
59
69
  }
@@ -63,17 +73,8 @@ function findBracedContent(str: string, after: number) {
63
73
  if (openIdx === -1) {
64
74
  return undefined
65
75
  }
66
- let depth = 1
67
- let pos = openIdx + 1
68
- while (pos < str.length && depth > 0) {
69
- if (str[pos] === '{') {
70
- depth++
71
- } else if (str[pos] === '}') {
72
- depth--
73
- }
74
- pos++
75
- }
76
- return depth === 0 ? str.slice(openIdx + 1, pos - 1).trim() : undefined
76
+ const end = afterMatchingBrace(str, openIdx)
77
+ return end === -1 ? undefined : str.slice(openIdx + 1, end - 1).trim()
77
78
  }
78
79
 
79
80
  export function parseAsn1(
@@ -163,22 +164,13 @@ function extractSections(asnString: string): Record<string, string> {
163
164
  continue
164
165
  }
165
166
 
166
- let depth = 1
167
- const start = pos + 1
168
- pos++
169
- while (pos < content.length && depth > 0) {
170
- if (content[pos] === '{') {
171
- depth++
172
- } else if (content[pos] === '}') {
173
- depth--
174
- }
175
- pos++
176
- }
177
- if (depth === 0) {
178
- sections[name] = content.slice(start, pos - 1).trim()
167
+ const end = afterMatchingBrace(content, pos)
168
+ if (end === -1) {
169
+ break
179
170
  }
171
+ sections[name] = content.slice(pos + 1, end - 1).trim()
180
172
 
181
- pos = content.indexOf(',', pos)
173
+ pos = content.indexOf(',', end)
182
174
  pos = pos === -1 ? content.length : pos + 1
183
175
  }
184
176
 
@@ -1,8 +1,9 @@
1
1
  // @vitest-environment jsdom
2
2
  //
3
- // Verifies the domain color-key legend is drawn as a reserved column on the
4
- // right of the SVG export when domains are shown. Polyfills mirror
5
- // impgRender.test.tsx (svgcanvas needs DOMMatrix/DOMPoint + a measureText).
3
+ // Verifies the SVG export draws the domain overlay: the boxes themselves across
4
+ // the full alignment width, and the color-key legend as a reserved column on the
5
+ // right. Polyfills mirror impgRender.test.tsx (svgcanvas needs
6
+ // DOMMatrix/DOMPoint + a measureText).
6
7
  import { createJBrowseTheme } from '@jbrowse/core/ui/theme'
7
8
  import { enableStaticRendering } from 'mobx-react'
8
9
  import { beforeAll, expect, test } from 'vitest'
@@ -76,13 +77,17 @@ ACDEFGHIKL
76
77
  >seq2
77
78
  ACDE-GHIKL`
78
79
 
79
- function makeModel() {
80
+ function makeModel({
81
+ data = msa,
82
+ start = 1,
83
+ end = 5,
84
+ }: { data?: string; start?: number; end?: number } = {}) {
80
85
  const model = MSAModelF().create({
81
- id: 'domain-legend-test',
86
+ id: 'domain-svg-test',
82
87
  type: 'MsaView',
83
88
  height: 400,
84
89
  msaFormat: 'fasta',
85
- data: { msa },
90
+ data: { msa: data },
86
91
  })
87
92
  model.setWidth(800)
88
93
  model.setDomains({
@@ -97,7 +102,7 @@ function makeModel() {
97
102
  description: 'Protein kinase domain',
98
103
  },
99
104
  },
100
- locations: [{ start: 1, end: 5 }],
105
+ locations: [{ start, end }],
101
106
  },
102
107
  ],
103
108
  },
@@ -105,18 +110,21 @@ function makeModel() {
105
110
  return model
106
111
  }
107
112
 
108
- test('domain legend is drawn in the svg export when domains are shown', async () => {
109
- const model = makeModel()
110
- expect(model.actuallyShowDomains).toBe(true)
111
- expect(model.visibleDomainTypes.map(d => d.accession)).toEqual(['PF00069'])
112
-
113
- const svg = await renderToSvg(model, {
113
+ function exportEntire(model: ReturnType<typeof makeModel>) {
114
+ return renderToSvg(model, {
114
115
  theme: createJBrowseTheme(),
115
116
  exportType: 'entire',
116
117
  includeMinimap: false,
117
118
  includeTracks: false,
118
119
  })
119
- expect(svg).toContain('Kinase')
120
+ }
121
+
122
+ test('domain legend is drawn in the svg export when domains are shown', async () => {
123
+ const model = makeModel()
124
+ expect(model.actuallyShowDomains).toBe(true)
125
+ expect(model.visibleDomainTypes.map(d => d.accession)).toEqual(['PF00069'])
126
+
127
+ expect(await exportEntire(model)).toContain('Kinase')
120
128
  })
121
129
 
122
130
  test('no legend column when domains are hidden', async () => {
@@ -124,11 +132,27 @@ test('no legend column when domains are hidden', async () => {
124
132
  model.setShowDomains(false)
125
133
  expect(model.actuallyShowDomains).toBe(false)
126
134
 
127
- const svg = await renderToSvg(model, {
128
- theme: createJBrowseTheme(),
129
- exportType: 'entire',
130
- includeMinimap: false,
131
- includeTracks: false,
135
+ expect(await exportEntire(model)).not.toContain('Kinase')
136
+ })
137
+
138
+ test('a domain box past the on-screen block size still draws', async () => {
139
+ // the export renders the alignment in one pass, so the overlay has to be told
140
+ // how wide that pass is; falling back to the 500px on-screen block size culled
141
+ // every band to the left edge of a wide alignment
142
+ const row = 'ACDEFGHIKL'.repeat(20)
143
+ const model = makeModel({
144
+ data: `>seq1\n${row}\n>seq2\n${row}`,
145
+ start: 151,
146
+ end: 190,
132
147
  })
133
- expect(svg).not.toContain('Kinase')
148
+ const band = model.domainBands.get('seq1')![0]!
149
+ const x = band.startCol * model.colWidth
150
+ expect(x).toBeGreaterThan(model.blockSize)
151
+
152
+ const svg = await exportEntire(model)
153
+ const fill = model.fillPalette.PF00069!
154
+ const rects = [...svg.matchAll(/<rect[^>]*>/g)].map(m => m[0])
155
+ expect(
156
+ rects.some(r => r.includes(`fill="${fill}"`) && r.includes(`x="${x}"`)),
157
+ ).toBe(true)
134
158
  })
@@ -251,6 +251,9 @@ function CoreRendering({
251
251
  ctx: msaCtx,
252
252
  offsetX,
253
253
  offsetY,
254
+ // the overlay culls bands to this width; without it the export falls back
255
+ // to the on-screen 500px block size and drops every band past it
256
+ blockSizeXOverride: msaAreaWidth,
254
257
  blockSizeYOverride: contentHeight,
255
258
  highResScaleFactorOverride: 1,
256
259
  })
@@ -1,71 +1,59 @@
1
+ // seqPos -> global column, exercised through the model method rather than the
2
+ // buildSeqPosIndex primitive behind it: the method is what
3
+ // jbrowse-plugin-protein3d calls (see the cross-repo note in model.ts), and it
4
+ // owns the past-the-end answers the index alone cannot give.
1
5
  import { describe, expect, test } from 'vitest'
2
6
 
3
- import { seqPosToGlobalCol } from './seqPosToGlobalCol.ts'
7
+ import MSAModelF from './model.ts'
8
+
9
+ function colsOf(row: string, positions: number[]) {
10
+ const model = MSAModelF().create({
11
+ type: 'MsaView',
12
+ data: { msa: `>r\n${row}` },
13
+ })
14
+ model.setWidth(800)
15
+ return positions.map(pos => model.seqPosToGlobalCol('r', pos))
16
+ }
4
17
 
5
18
  describe('seqPosToGlobalCol', () => {
6
- test('converts sequence position to global column with no gaps', () => {
7
- const row = 'ATGCATGC'
8
- expect(seqPosToGlobalCol({ row, seqPos: 0 })).toBe(0)
9
- expect(seqPosToGlobalCol({ row, seqPos: 3 })).toBe(3)
10
- expect(seqPosToGlobalCol({ row, seqPos: 7 })).toBe(7)
11
- expect(seqPosToGlobalCol({ row, seqPos: 8 })).toBe(8) // Past end
19
+ test('is the identity when the row has no gaps', () => {
20
+ // 8 residues, so seqPos 8 is one past the end
21
+ expect(colsOf('ATGCATGC', [0, 3, 7, 8])).toEqual([0, 3, 7, 8])
12
22
  })
13
23
 
14
- test('converts sequence position to global column with gaps', () => {
15
- const row = 'A-TG-CA-TGC'
24
+ test('skips gap columns', () => {
16
25
  // Global: A(0) -(1) T(2) G(3) -(4) C(5) A(6) -(7) T(8) G(9) C(10)
17
26
  // SeqPos: A(0) T(1) G(2) C(3) A(4) T(5) G(6) C(7)
18
-
19
- expect(seqPosToGlobalCol({ row, seqPos: 0 })).toBe(0) // A
20
- expect(seqPosToGlobalCol({ row, seqPos: 1 })).toBe(2) // T
21
- expect(seqPosToGlobalCol({ row, seqPos: 2 })).toBe(3) // G
22
- expect(seqPosToGlobalCol({ row, seqPos: 3 })).toBe(5) // C
23
- expect(seqPosToGlobalCol({ row, seqPos: 4 })).toBe(6) // A
24
- expect(seqPosToGlobalCol({ row, seqPos: 5 })).toBe(8) // T
25
- expect(seqPosToGlobalCol({ row, seqPos: 6 })).toBe(9) // G
26
- expect(seqPosToGlobalCol({ row, seqPos: 7 })).toBe(10) // C
27
- expect(seqPosToGlobalCol({ row, seqPos: 8 })).toBe(11) // Past end
28
- })
29
-
30
- test('handles empty row', () => {
31
- expect(seqPosToGlobalCol({ row: '', seqPos: 0 })).toBe(0)
27
+ expect(colsOf('A-TG-CA-TGC', [0, 1, 2, 3, 4, 5, 6, 7, 8])).toEqual([
28
+ 0, 2, 3, 5, 6, 8, 9, 10, 11,
29
+ ])
32
30
  })
33
31
 
34
- test('handles row with only gaps', () => {
35
- const row = '---..--'
36
- expect(seqPosToGlobalCol({ row, seqPos: 0 })).toBe(0)
37
- expect(seqPosToGlobalCol({ row, seqPos: 1 })).toBe(7) // Past end
38
- })
39
-
40
- test('handles mixed gap characters (- and .)', () => {
41
- const row = 'A-.G-C.'
32
+ test('counts both gap characters, - and .', () => {
42
33
  // Global: A(0) -(1) .(2) G(3) -(4) C(5) .(6)
43
34
  // SeqPos: A(0) G(1) C(2)
44
-
45
- expect(seqPosToGlobalCol({ row, seqPos: 0 })).toBe(0) // A
46
- expect(seqPosToGlobalCol({ row, seqPos: 1 })).toBe(3) // G
47
- expect(seqPosToGlobalCol({ row, seqPos: 2 })).toBe(5) // C
48
- expect(seqPosToGlobalCol({ row, seqPos: 3 })).toBe(7) // Past end
35
+ expect(colsOf('A-.G-C.', [0, 1, 2, 3])).toEqual([0, 3, 5, 7])
49
36
  })
50
37
 
51
- test('handles leading gaps', () => {
52
- const row = '--ACG'
53
- // Global: -(0) -(1) A(2) C(3) G(4)
54
- // SeqPos: A(0) C(1) G(2)
38
+ test('handles leading and trailing gaps', () => {
39
+ expect(colsOf('--ACG', [0, 1, 2])).toEqual([2, 3, 4])
40
+ expect(colsOf('ACG--', [0, 1, 2, 3])).toEqual([0, 1, 2, 5])
41
+ })
55
42
 
56
- expect(seqPosToGlobalCol({ row, seqPos: 0 })).toBe(2) // A
57
- expect(seqPosToGlobalCol({ row, seqPos: 1 })).toBe(3) // C
58
- expect(seqPosToGlobalCol({ row, seqPos: 2 })).toBe(4) // G
43
+ test('an all-gap row reports position 0 at column 0', () => {
44
+ expect(colsOf('---..--', [0, 1])).toEqual([0, 7])
59
45
  })
60
46
 
61
- test('handles trailing gaps', () => {
62
- const row = 'ACG--'
63
- // Global: A(0) C(1) G(2) -(3) -(4)
64
- // SeqPos: A(0) C(1) G(2)
47
+ test('an empty row reports column 0', () => {
48
+ expect(colsOf('', [0])).toEqual([0])
49
+ })
65
50
 
66
- expect(seqPosToGlobalCol({ row, seqPos: 0 })).toBe(0) // A
67
- expect(seqPosToGlobalCol({ row, seqPos: 1 })).toBe(1) // C
68
- expect(seqPosToGlobalCol({ row, seqPos: 2 })).toBe(2) // G
69
- expect(seqPosToGlobalCol({ row, seqPos: 3 })).toBe(5) // Past end
51
+ test('an unknown row reports column 0', () => {
52
+ const model = MSAModelF().create({
53
+ type: 'MsaView',
54
+ data: { msa: '>r\nACGT' },
55
+ })
56
+ model.setWidth(800)
57
+ expect(model.seqPosToGlobalCol('nope', 2)).toBe(0)
70
58
  })
71
59
  })
@@ -1,25 +1,14 @@
1
- import { isBlank } from './util.ts'
2
-
3
1
  /**
4
- * Convert a sequence position (ungapped, 0-based) to a global column index.
5
- * This finds the global column that contains the Nth non-gap character.
2
+ * The global column of every ungapped sequence position in a row, i.e.
3
+ * index[seqPos] is the column holding the seqPos-th non-gap character.
6
4
  *
7
- * @param row - The row's sequence string (including gaps)
8
- * @param seqPos - The sequence position (0-based count of non-gap characters)
9
- * @returns The global column index containing the seqPos-th non-gap character
5
+ * A row is indexed once and then answers seqPos -> column by lookup, which is
6
+ * what makes the domain overlay affordable: it resolves one position per feature
7
+ * per row on every redraw, and scanning the row for each would be quadratic.
10
8
  *
11
9
  * @example
12
10
  * // Row: "A-TG-C" (A at 0, T at 2, G at 3, C at 5)
13
- * seqPosToGlobalCol({ row: "A-TG-C", seqPos: 0 }) // → 0 (A)
14
- * seqPosToGlobalCol({ row: "A-TG-C", seqPos: 1 }) // → 2 (T)
15
- * seqPosToGlobalCol({ row: "A-TG-C", seqPos: 2 }) // → 3 (G)
16
- * seqPosToGlobalCol({ row: "A-TG-C", seqPos: 3 }) // → 5 (C)
17
- */
18
- /**
19
- * The global column of every ungapped sequence position in a row, i.e.
20
- * index[seqPos] is the column holding the seqPos-th non-gap character. Amortizes
21
- * repeated seqPosToGlobalCol lookups (the domain overlay does one per feature
22
- * per row) into a single pass per row.
11
+ * buildSeqPosIndex('A-TG-C') // → [0, 2, 3, 5]
23
12
  */
24
13
  export function buildSeqPosIndex(row: string) {
25
14
  const index = new Int32Array(row.length)
@@ -32,27 +21,3 @@ export function buildSeqPosIndex(row: string) {
32
21
  }
33
22
  return index.subarray(0, n)
34
23
  }
35
-
36
- export function seqPosToGlobalCol({
37
- row,
38
- seqPos,
39
- }: {
40
- row: string
41
- seqPos: number
42
- }) {
43
- let nonGapCount = 0
44
- let globalCol = 0
45
- // Find the seqPos-th non-gap character
46
- while (globalCol < row.length) {
47
- if (!isBlank(row[globalCol])) {
48
- if (nonGapCount === seqPos) {
49
- return globalCol
50
- }
51
- nonGapCount++
52
- }
53
- globalCol++
54
- }
55
- // If seqPos is 0 and we didn't find any non-gap character, return 0
56
- // Otherwise return globalCol (which is row.length at this point)
57
- return seqPos === 0 ? 0 : globalCol
58
- }