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.
- package/bundle/index.js +2 -2
- package/bundle/index.js.map +3 -3
- package/dist/colorSchemes.js +2 -0
- package/dist/colorSchemes.js.map +1 -1
- package/dist/components/msa/msaRaster.js +16 -11
- package/dist/components/msa/msaRaster.js.map +1 -1
- package/dist/components/tracks/drawTracks.js +4 -5
- package/dist/components/tracks/drawTracks.js.map +1 -1
- package/dist/components/tree/renderTreeCanvas.js +7 -3
- package/dist/components/tree/renderTreeCanvas.js.map +1 -1
- package/dist/model.d.ts +2 -2
- package/dist/model.js +10 -5
- package/dist/model.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/src/colorSchemes.ts +4 -2
- package/src/components/msa/msaRaster.ts +22 -17
- package/src/components/tracks/drawTracks.ts +4 -5
- package/src/components/tree/renderTreeCanvas.test.ts +65 -0
- package/src/components/tree/renderTreeCanvas.ts +6 -3
- package/src/dataInitialized.test.ts +42 -0
- package/src/model.ts +10 -5
- package/src/reset.test.ts +61 -0
- package/src/version.ts +1 -1
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
|
85
|
+
const fill = bgColor ? colorScheme[upper] : undefined
|
|
86
86
|
const x = (xStart + i) * colWidth
|
|
87
|
-
|
|
88
|
-
|
|
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 =
|
|
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:
|
|
326
|
-
maxX:
|
|
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
|
-
|
|
668
|
-
self.
|
|
667
|
+
setTreeMetadataFilehandle(treeMetadataFilehandle?: FileLocationType) {
|
|
668
|
+
self.treeMetadataFilehandle = treeMetadataFilehandle
|
|
669
669
|
},
|
|
670
670
|
|
|
671
671
|
/**
|
|
672
672
|
* #action
|
|
673
673
|
*/
|
|
674
|
-
|
|
675
|
-
self.
|
|
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
|
-
|
|
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
|
+
export const version = '6.2.2'
|