react-msaview 6.2.1 → 6.2.2

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.
@@ -180,7 +180,7 @@ interface RasterCache {
180
180
  numColumns: number
181
181
  numRows: number
182
182
  tiles: Map<string, RasterCanvas>
183
- thumbnail?: RasterCanvas
183
+ thumbnail?: { height: number; canvas: RasterCanvas | undefined }
184
184
  }
185
185
 
186
186
  const caches = new WeakMap<object, RasterCache>()
@@ -341,7 +341,7 @@ export function canvasHref(canvas: RasterCanvas | undefined) {
341
341
  if (typeof ctx?.drawImage !== 'function') {
342
342
  return undefined
343
343
  }
344
- ctx.drawImage(canvas as CanvasImageSource, 0, 0)
344
+ ctx.drawImage(canvas, 0, 0)
345
345
  return out.toDataURL('image/png')
346
346
  } catch {
347
347
  return undefined
@@ -434,24 +434,29 @@ export function msaThumbnail({
434
434
  if (numColumns <= 0 || numRows <= 0) {
435
435
  return undefined
436
436
  }
437
- if (!cache.thumbnail) {
437
+ // keyed on the requested height as well as the cache: rowStep is derived from
438
+ // it, so a thumbnail built for a different bar height is the wrong sampling
439
+ if (cache.thumbnail?.height !== height) {
438
440
  const colStep = Math.max(1, Math.ceil(numColumns / maxThumbnailColumns))
439
441
  const rowStep = Math.max(1, Math.ceil(numRows / height))
440
442
  const w = Math.ceil(numColumns / colStep)
441
443
  const h = Math.ceil(numRows / rowStep)
442
- cache.thumbnail = paint(
443
- rasterPixels({
444
- spec: cache.spec,
445
- col0: 0,
446
- row0: 0,
447
- width: w,
448
- height: h,
449
- colStep,
450
- rowStep,
451
- }),
452
- w,
453
- h,
454
- )
444
+ cache.thumbnail = {
445
+ height,
446
+ canvas: paint(
447
+ rasterPixels({
448
+ spec: cache.spec,
449
+ col0: 0,
450
+ row0: 0,
451
+ width: w,
452
+ height: h,
453
+ colStep,
454
+ rowStep,
455
+ }),
456
+ w,
457
+ h,
458
+ ),
459
+ }
455
460
  }
456
- return cache.thumbnail
461
+ return cache.thumbnail.canvas
457
462
  }
@@ -82,17 +82,16 @@ export function drawTextTrackContent({
82
82
  for (let i = 0; str && i < str.length; i++) {
83
83
  const letter = str[i]!
84
84
  const upper = letter.toUpperCase()
85
- const color = colorScheme[upper]
85
+ const fill = bgColor ? colorScheme[upper] : undefined
86
86
  const x = (xStart + i) * colWidth
87
- const filled = bgColor && !!color
88
- if (filled) {
89
- ctx.fillStyle = color!
87
+ if (fill) {
88
+ ctx.fillStyle = fill
90
89
  ctx.fillRect(x, 0, colWidth, rowHeight)
91
90
  }
92
91
  if (drawLetters) {
93
92
  // a letter on a colored tile needs the tile's contrast color; otherwise it
94
93
  // sits on the plain background and takes the theme's text color
95
- ctx.fillStyle = filled ? (contrastScheme[upper] ?? 'black') : textColor
94
+ ctx.fillStyle = fill ? (contrastScheme[upper] ?? 'black') : textColor
96
95
  ctx.fillText(letter, x + colWidth / 2, rowHeight / 2 + 1)
97
96
  }
98
97
  }
@@ -229,3 +229,68 @@ describe('node bubble click targets', () => {
229
229
  expect(hits.filter(h => h.branch).length).toBe(3)
230
230
  })
231
231
  })
232
+
233
+ describe('leaf label click targets', () => {
234
+ const bounds = { minX: 0, minY: 0, maxX: 10000, maxY: 10000 }
235
+ // the recorded fillText x is in content coordinates -- the fake ctx applies
236
+ // the same translate the real one does -- so it is directly comparable to the
237
+ // clickMap box, which is what the hit test searches
238
+ function renderLabels(labelsAlignRight: boolean) {
239
+ const model = stateModelFactory().create({
240
+ type: 'MsaView',
241
+ data: { msa: '>a\nA\n>b\nA', tree: '(a,b);' },
242
+ })
243
+ model.setWidth(1000)
244
+ model.setDrawTree(false)
245
+ model.setLabelsAlignRight(labelsAlignRight)
246
+
247
+ let tx = 0
248
+ const drawn: number[] = []
249
+ const ctx = {
250
+ font: '12px sans-serif',
251
+ measureText: (text: string) => ({ width: text.length * 6 }),
252
+ beginPath() {},
253
+ stroke() {},
254
+ setLineDash() {},
255
+ resetTransform() {
256
+ tx = 0
257
+ },
258
+ scale() {},
259
+ translate(x: number) {
260
+ tx += x
261
+ },
262
+ moveTo() {},
263
+ lineTo() {},
264
+ fillText(_text: string, x: number) {
265
+ drawn.push(x + tx)
266
+ },
267
+ } as unknown as RenderCtx
268
+
269
+ const clickMap = new ClickMapIndex()
270
+ renderTreeCanvas({
271
+ model,
272
+ ctx,
273
+ clickMap,
274
+ offsetY: 0,
275
+ theme: { palette: { text: { primary: '#000' } } } as Theme,
276
+ })
277
+ return { drawn, hits: clickMap.search(bounds).filter(h => !h.branch) }
278
+ }
279
+
280
+ it('boxes a right-aligned label at the edge it is drawn against', () => {
281
+ const { drawn, hits } = renderLabels(true)
282
+ expect(hits.length).toBe(drawn.length)
283
+ // textAlign is 'right' here, so the drawn x is the label's right edge
284
+ for (const hit of hits) {
285
+ expect(drawn).toContain(hit.maxX)
286
+ }
287
+ })
288
+
289
+ it('boxes a left-aligned label at the edge it is drawn from', () => {
290
+ const { drawn, hits } = renderLabels(false)
291
+ expect(hits.length).toBe(drawn.length)
292
+ for (const hit of hits) {
293
+ expect(drawn).toContain(hit.minX)
294
+ }
295
+ })
296
+ })
@@ -265,7 +265,6 @@ function renderTreeLabels({
265
265
  blockSize,
266
266
  labelsAlignRight,
267
267
  drawTree,
268
- treeAreaWidth,
269
268
  treeAreaWidthMinusMargin,
270
269
  marginLeft,
271
270
  noTree,
@@ -321,9 +320,13 @@ function renderTreeLabels({
321
320
  ctx.stroke()
322
321
  }
323
322
  ctx.fillText(displayName, offset, yp)
323
+ // everything here is drawn under translate(marginLeft), so the click box
324
+ // has to be the text's own right edge in that same space -- taking
325
+ // treeAreaWidth instead put the target smallPadding off the label
326
+ const labelRight = offset + marginLeft
324
327
  clickMap?.insert({
325
- minX: treeAreaWidth - width,
326
- maxX: treeAreaWidth,
328
+ minX: labelRight - width,
329
+ maxX: labelRight,
327
330
  minY: yp - emHeight,
328
331
  maxY: yp,
329
332
  name,
@@ -0,0 +1,42 @@
1
+ import { getSnapshot } from '@jbrowse/mobx-state-tree'
2
+ import { expect, test } from 'vitest'
3
+
4
+ import stateModelFactory from './model.ts'
5
+
6
+ const MsaView = stateModelFactory()
7
+
8
+ test('an empty model is not initialized', () => {
9
+ expect(MsaView.create({ type: 'MsaView' }).dataInitialized).toBe(false)
10
+ })
11
+
12
+ test('inline data initializes the view', () => {
13
+ const model = MsaView.create({
14
+ type: 'MsaView',
15
+ data: { msa: '>a\nACGT\n>b\nACGA\n', tree: '' },
16
+ })
17
+ expect(model.dataInitialized).toBe(true)
18
+ })
19
+
20
+ // DataModel's postProcessSnapshot drops a document over 50kb rather than
21
+ // inlining it into a session or a shared URL, so it comes back `undefined`
22
+ // rather than ''. That has to read as "no data" -- otherwise the restored
23
+ // session renders an empty alignment instead of the import form
24
+ test('a dropped oversize inline msa restores as uninitialized', () => {
25
+ const msa = `>a\n${'A'.repeat(60_000)}\n>b\n${'C'.repeat(60_000)}\n`
26
+ const model = MsaView.create({ type: 'MsaView', data: { msa, tree: '' } })
27
+ expect(model.dataInitialized).toBe(true)
28
+
29
+ const restored = MsaView.create(getSnapshot(model))
30
+ expect(restored.data.msa).toBeUndefined()
31
+ expect(restored.MSA).toBe(null)
32
+ expect(restored.dataInitialized).toBe(false)
33
+ })
34
+
35
+ test('an error keeps the view uninitialized', () => {
36
+ const model = MsaView.create({
37
+ type: 'MsaView',
38
+ data: { msa: '>a\nACGT\n', tree: '' },
39
+ })
40
+ model.setError(new Error('boom'))
41
+ expect(model.dataInitialized).toBe(false)
42
+ })
package/src/model.ts CHANGED
@@ -664,15 +664,15 @@ function stateModelFactory() {
664
664
  /**
665
665
  * #action
666
666
  */
667
- setGFFFilehandle(gffFilehandle?: FileLocationType) {
668
- self.gffFilehandle = gffFilehandle
667
+ setTreeMetadataFilehandle(treeMetadataFilehandle?: FileLocationType) {
668
+ self.treeMetadataFilehandle = treeMetadataFilehandle
669
669
  },
670
670
 
671
671
  /**
672
672
  * #action
673
673
  */
674
- setTreeMetadataFilehandle(treeMetadataFilehandle?: FileLocationType) {
675
- self.treeMetadataFilehandle = treeMetadataFilehandle
674
+ setGFFFilehandle(gffFilehandle?: FileLocationType) {
675
+ self.gffFilehandle = gffFilehandle
676
676
  },
677
677
 
678
678
  /**
@@ -1231,7 +1231,12 @@ function stateModelFactory() {
1231
1231
  * #getter
1232
1232
  */
1233
1233
  get dataInitialized() {
1234
- return (self.data.msa !== '' || self.data.tree !== '') && !self.error
1234
+ // truthiness, not `!== ''`: these are types.maybe, and DataModel's
1235
+ // postProcessSnapshot drops a document over 50kb, so a restored session
1236
+ // that inlined a large alignment comes back `undefined` here -- which
1237
+ // `!== ''` reads as initialized and renders an empty view instead of
1238
+ // the import form
1239
+ return !!(self.data.msa || self.data.tree) && !self.error
1235
1240
  },
1236
1241
  /**
1237
1242
  * #getter
@@ -0,0 +1,61 @@
1
+ import { expect, test } from 'vitest'
2
+
3
+ import stateModelFactory from './model.ts'
4
+
5
+ const MsaView = stateModelFactory()
6
+
7
+ function loaded() {
8
+ const model = MsaView.create({
9
+ type: 'MsaView',
10
+ data: { msa: '>a\nAC\n>b\nAC\n>c\nAC\n>d\nAC', tree: '((a,b),(c,d));' },
11
+ })
12
+ model.setWidth(1000)
13
+ return model
14
+ }
15
+
16
+ test('reset returns to the import form', () => {
17
+ const model = loaded()
18
+ expect(model.dataInitialized).toBe(true)
19
+ model.reset()
20
+ expect(model.dataInitialized).toBe(false)
21
+ })
22
+
23
+ // generateNodeIds derives ids from the path, so 'node-0-0-1' is the root's
24
+ // first child in EVERY tree. Carrying collapsed/showOnly across a reset opens
25
+ // the next file collapsed, or focused on a subtree, with nothing on screen
26
+ // saying why
27
+ test('reset drops view state that names the old data', () => {
28
+ const model = loaded()
29
+ const innerNode = model.hierarchy.children![0]!.data.id
30
+ model.toggleCollapsed(innerNode)
31
+ model.setShowOnly(innerNode)
32
+ model.drawRelativeTo('a')
33
+ model.setHighlightedColumns([0, 1])
34
+ model.setMousePos(1, 1)
35
+ model.setMouseClickPos(1, 1)
36
+ model.setHoveredTreeNode(innerNode)
37
+
38
+ model.reset()
39
+
40
+ expect([...model.collapsed]).toEqual([])
41
+ expect(model.showOnly).toBeUndefined()
42
+ expect(model.relativeTo).toBeUndefined()
43
+ expect(model.highlightedColumns).toBeUndefined()
44
+ expect(model.mouseCol).toBeUndefined()
45
+ expect(model.mouseClickCol).toBeUndefined()
46
+ expect(model.hoveredTreeNode).toBeUndefined()
47
+ })
48
+
49
+ test('a file opened after a reset is not collapsed by the previous one', () => {
50
+ const model = loaded()
51
+ model.toggleCollapsed(model.hierarchy.children![0]!.data.id)
52
+ expect(model.numRows).toBeLessThan(4)
53
+
54
+ model.reset()
55
+ model.setData({
56
+ msa: '>x\nGT\n>y\nGT\n>z\nGT\n>w\nGT',
57
+ tree: '((x,y),(z,w));',
58
+ })
59
+ expect(model.numRows).toBe(4)
60
+ expect(model.rowNames).toEqual(['x', 'y', 'z', 'w'])
61
+ })
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '6.2.1'
1
+ export const version = '6.2.2'