react-msaview 5.4.0 → 5.5.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.
- package/LICENSE +21 -0
- package/bundle/index.js +465 -138
- package/bundle/index.js.map +7 -1
- package/dist/colorSchemes.js +11 -7
- package/dist/colorSchemes.js.map +1 -1
- package/dist/components/Checkbox2.js +3 -1
- package/dist/components/Checkbox2.js.map +1 -1
- package/dist/components/MSAViewer.d.ts +7 -1
- package/dist/components/MSAViewer.js +11 -10
- package/dist/components/MSAViewer.js.map +1 -1
- package/dist/components/Track.js +8 -5
- package/dist/components/Track.js.map +1 -1
- package/dist/components/dialogs/FeatureDialog.js +27 -23
- package/dist/components/dialogs/FeatureDialog.js.map +1 -1
- package/dist/components/dialogs/InterProScanDialog.js +2 -1
- package/dist/components/dialogs/InterProScanDialog.js.map +1 -1
- package/dist/components/header/MultiAlignmentSelector.js +0 -0
- package/dist/components/header/MultiAlignmentSelector.js.map +1 -1
- package/dist/components/msa/DomainLegend.d.ts +6 -0
- package/dist/components/msa/DomainLegend.js +41 -0
- package/dist/components/msa/DomainLegend.js.map +1 -0
- package/dist/components/msa/MSACanvasBlock.js +17 -5
- package/dist/components/msa/MSACanvasBlock.js.map +1 -1
- package/dist/components/msa/MSAPanel.js +3 -1
- package/dist/components/msa/MSAPanel.js.map +1 -1
- package/dist/components/msa/renderBoxFeatureCanvasBlock.js +58 -14
- package/dist/components/msa/renderBoxFeatureCanvasBlock.js.map +1 -1
- package/dist/components/msa/renderMSABlock.js +7 -9
- package/dist/components/msa/renderMSABlock.js.map +1 -1
- package/dist/components/msa/renderMSAMouseover.js +32 -4
- package/dist/components/msa/renderMSAMouseover.js.map +1 -1
- package/dist/components/renderCtx.d.ts +1 -1
- package/dist/components/tracks/renderTracksSvg.js +4 -4
- package/dist/components/tracks/renderTracksSvg.js.map +1 -1
- package/dist/components/tree/renderTreeCanvas.js +3 -0
- package/dist/components/tree/renderTreeCanvas.js.map +1 -1
- package/dist/constants.d.ts +3 -0
- package/dist/constants.js +16 -0
- package/dist/constants.js.map +1 -1
- package/dist/createPaletteMap.d.ts +5 -2
- package/dist/createPaletteMap.js +15 -5
- package/dist/createPaletteMap.js.map +1 -1
- package/dist/hierarchy.js +4 -1
- package/dist/hierarchy.js.map +1 -1
- package/dist/launchInterProScan.d.ts +2 -0
- package/dist/launchInterProScan.js.map +1 -1
- package/dist/model/msaModel.js.map +1 -1
- package/dist/model.d.ts +100 -3
- package/dist/model.js +121 -19
- package/dist/model.js.map +1 -1
- package/dist/neighborJoining.js +44 -30
- package/dist/neighborJoining.js.map +1 -1
- package/dist/renderToSvg.js +104 -74
- package/dist/renderToSvg.js.map +1 -1
- package/dist/{webpack.js → umd.js} +1 -1
- package/dist/umd.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +8 -7
- package/src/colorSchemes.ts +12 -7
- package/src/components/Checkbox2.tsx +7 -1
- package/src/components/MSAViewer.tsx +31 -21
- package/src/components/Track.tsx +8 -5
- package/src/components/dialogs/FeatureDialog.tsx +62 -48
- package/src/components/dialogs/InterProScanDialog.tsx +2 -1
- package/src/components/header/MultiAlignmentSelector.tsx +0 -0
- package/src/components/msa/DomainLegend.tsx +74 -0
- package/src/components/msa/MSACanvasBlock.tsx +22 -4
- package/src/components/msa/MSAPanel.tsx +2 -0
- package/src/components/msa/renderBoxFeatureCanvasBlock.ts +73 -17
- package/src/components/msa/renderMSABlock.ts +5 -11
- package/src/components/msa/renderMSAMouseover.ts +32 -4
- package/src/components/renderCtx.ts +1 -0
- package/src/components/tracks/renderTracksSvg.ts +4 -4
- package/src/components/tree/renderTreeCanvas.ts +5 -1
- package/src/constants.ts +18 -0
- package/src/createPaletteMap.ts +18 -5
- package/src/hierarchy.ts +4 -1
- package/src/launchInterProScan.ts +3 -1
- package/src/model/msaModel.ts +3 -1
- package/src/model.ts +137 -36
- package/src/neighborJoining.ts +44 -31
- package/src/renderDomainLegendSvg.test.tsx +134 -0
- package/src/renderToSvg.tsx +210 -147
- package/src/segmentOverlay.test.ts +70 -0
- package/src/version.ts +1 -1
- package/bundle/index.js.LICENSE.txt +0 -100
- package/dist/components/util.d.ts +0 -1
- package/dist/components/util.js +0 -7
- package/dist/components/util.js.map +0 -1
- package/dist/webpack.js.map +0 -1
- package/src/components/util.ts +0 -9
- /package/dist/{webpack.d.ts → umd.d.ts} +0 -0
- /package/src/{webpack.ts → umd.ts} +0 -0
package/src/model.ts
CHANGED
|
@@ -43,6 +43,8 @@ import {
|
|
|
43
43
|
maxCellSize,
|
|
44
44
|
minColWidth,
|
|
45
45
|
minRowHeight,
|
|
46
|
+
segmentFeatureTypes,
|
|
47
|
+
segmentShades,
|
|
46
48
|
} from './constants.ts'
|
|
47
49
|
import { createPaletteMap } from './createPaletteMap.ts'
|
|
48
50
|
import { fetchTextWithProgress, isAbortError } from './fetchUtils.ts'
|
|
@@ -95,6 +97,14 @@ function parseTreeText(text: string) {
|
|
|
95
97
|
|
|
96
98
|
/**
|
|
97
99
|
* #stateModel MsaView
|
|
100
|
+
*
|
|
101
|
+
* The main MSAView state model. Holds the loaded alignment, tree, and optional
|
|
102
|
+
* InterProScan domain annotations, plus all display state (color scheme, zoom,
|
|
103
|
+
* scroll, collapsed clades). It composes in members from `DialogQueueSessionMixin`,
|
|
104
|
+
* `Tree`, and `MSAModel` (see Inherited members below). Data is loaded reactively
|
|
105
|
+
* from the `msaFilehandle` / `treeFilehandle` / `gffFilehandle` properties, or set
|
|
106
|
+
* directly with `setData`. Most state is persisted into the shareable URL.
|
|
107
|
+
*
|
|
98
108
|
* #example
|
|
99
109
|
* ```js
|
|
100
110
|
* import { MSAModelF } from 'react-msaview'
|
|
@@ -249,6 +259,15 @@ function stateModelFactory() {
|
|
|
249
259
|
* #property
|
|
250
260
|
*/
|
|
251
261
|
relativeTo: types.maybe(types.string),
|
|
262
|
+
/**
|
|
263
|
+
* #property
|
|
264
|
+
* declarative seed for the highlighted-columns overlay (visible column
|
|
265
|
+
* indices). Unlike the volatile `highlightedColumns` (driven by
|
|
266
|
+
* transient genome-hover sync), this persists in the snapshot/URL so a
|
|
267
|
+
* shared link can open with specific columns highlighted. Applied once
|
|
268
|
+
* in afterCreate.
|
|
269
|
+
*/
|
|
270
|
+
highlightColumns: types.frozen<number[] | undefined>(),
|
|
252
271
|
}),
|
|
253
272
|
)
|
|
254
273
|
.volatile(() => ({
|
|
@@ -364,12 +383,15 @@ function stateModelFactory() {
|
|
|
364
383
|
.actions(self => ({
|
|
365
384
|
/**
|
|
366
385
|
* #action
|
|
386
|
+
* draw the alignment with positions numbered relative to the given row's
|
|
387
|
+
* sequence (its node id), instead of in raw MSA-column coordinates
|
|
367
388
|
*/
|
|
368
389
|
drawRelativeTo(id: string | undefined) {
|
|
369
390
|
self.relativeTo = id
|
|
370
391
|
},
|
|
371
392
|
/**
|
|
372
393
|
* #action
|
|
394
|
+
* hide columns that are entirely (or mostly, see allowedGappyness) gaps
|
|
373
395
|
*/
|
|
374
396
|
setHideGaps(arg: boolean) {
|
|
375
397
|
self.hideGaps = arg
|
|
@@ -448,6 +470,7 @@ function stateModelFactory() {
|
|
|
448
470
|
},
|
|
449
471
|
/**
|
|
450
472
|
* #action
|
|
473
|
+
* toggle the InterProScan protein-domain overlay on the alignment
|
|
451
474
|
*/
|
|
452
475
|
setShowDomains(arg: boolean) {
|
|
453
476
|
self.showDomains = arg
|
|
@@ -501,6 +524,7 @@ function stateModelFactory() {
|
|
|
501
524
|
|
|
502
525
|
/**
|
|
503
526
|
* #action
|
|
527
|
+
* collapse or un-collapse the subtree rooted at the given tree node id
|
|
504
528
|
*/
|
|
505
529
|
toggleCollapsed(node: string) {
|
|
506
530
|
if (self.collapsed.includes(node)) {
|
|
@@ -512,6 +536,8 @@ function stateModelFactory() {
|
|
|
512
536
|
|
|
513
537
|
/**
|
|
514
538
|
* #action
|
|
539
|
+
* show only the subtree rooted at the given node id (pass undefined to
|
|
540
|
+
* show the whole tree again)
|
|
515
541
|
*/
|
|
516
542
|
setShowOnly(node?: string) {
|
|
517
543
|
self.showOnly = node
|
|
@@ -519,6 +545,8 @@ function stateModelFactory() {
|
|
|
519
545
|
|
|
520
546
|
/**
|
|
521
547
|
* #action
|
|
548
|
+
* set the alignment/tree/metadata/domain data directly from strings,
|
|
549
|
+
* bypassing the filehandle loaders
|
|
522
550
|
*/
|
|
523
551
|
setData(data: {
|
|
524
552
|
msa?: string
|
|
@@ -944,7 +972,10 @@ function stateModelFactory() {
|
|
|
944
972
|
get colConsensus() {
|
|
945
973
|
const { colStats, colStatsSums } = this
|
|
946
974
|
return colStats.map((stats, i) => {
|
|
947
|
-
const total = colStatsSums[i]
|
|
975
|
+
const total = colStatsSums[i]
|
|
976
|
+
if (!total) {
|
|
977
|
+
return { letter: '', color: undefined }
|
|
978
|
+
}
|
|
948
979
|
let maxCount = 0
|
|
949
980
|
let letter = ''
|
|
950
981
|
for (const [key, val] of Object.entries(stats)) {
|
|
@@ -1220,6 +1251,7 @@ function stateModelFactory() {
|
|
|
1220
1251
|
|
|
1221
1252
|
/**
|
|
1222
1253
|
* #action
|
|
1254
|
+
* restore the default column width and row height
|
|
1223
1255
|
*/
|
|
1224
1256
|
resetZoom() {
|
|
1225
1257
|
self.setColWidth(defaultColWidth)
|
|
@@ -1259,12 +1291,8 @@ function stateModelFactory() {
|
|
|
1259
1291
|
*/
|
|
1260
1292
|
zoomIn() {
|
|
1261
1293
|
transaction(() => {
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
maxCellSize,
|
|
1265
|
-
Math.ceil(self.rowHeight * 1.5),
|
|
1266
|
-
)
|
|
1267
|
-
self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
|
|
1294
|
+
this.zoomInHorizontal()
|
|
1295
|
+
this.zoomInVertical()
|
|
1268
1296
|
})
|
|
1269
1297
|
},
|
|
1270
1298
|
/**
|
|
@@ -1272,15 +1300,8 @@ function stateModelFactory() {
|
|
|
1272
1300
|
*/
|
|
1273
1301
|
zoomOut() {
|
|
1274
1302
|
transaction(() => {
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
Math.floor(self.colWidth * 0.75),
|
|
1278
|
-
)
|
|
1279
|
-
self.rowHeight = Math.max(
|
|
1280
|
-
minRowHeight,
|
|
1281
|
-
Math.floor(self.rowHeight * 0.75),
|
|
1282
|
-
)
|
|
1283
|
-
self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
|
|
1303
|
+
this.zoomOutHorizontal()
|
|
1304
|
+
this.zoomOutVertical()
|
|
1284
1305
|
})
|
|
1285
1306
|
},
|
|
1286
1307
|
/**
|
|
@@ -1314,9 +1335,13 @@ function stateModelFactory() {
|
|
|
1314
1335
|
)
|
|
1315
1336
|
|
|
1316
1337
|
const anchoredScrollY = offsetY - rowInView * self.rowHeight
|
|
1317
|
-
|
|
1338
|
+
// maxScrollY is -(totalHeight - visibleMsaHeight) when the alignment
|
|
1339
|
+
// overflows, so -maxScrollY is exactly that overflow past the
|
|
1340
|
+
// scrollable MSA viewport (0 when it fits)
|
|
1341
|
+
const overflow = Math.max(0, -self.maxScrollY)
|
|
1342
|
+
const visibleHeight = self.totalHeight - overflow
|
|
1318
1343
|
const topBias =
|
|
1319
|
-
|
|
1344
|
+
visibleHeight > 0 ? clamp(1 - overflow / visibleHeight, 0, 1) : 1
|
|
1320
1345
|
self.scrollY = clamp(
|
|
1321
1346
|
anchoredScrollY * (1 - topBias),
|
|
1322
1347
|
self.maxScrollY,
|
|
@@ -1587,13 +1612,15 @@ function stateModelFactory() {
|
|
|
1587
1612
|
val.matches.flatMap(({ signature, locations }) => {
|
|
1588
1613
|
const { entry } = signature
|
|
1589
1614
|
return entry
|
|
1590
|
-
? locations.map(({ start, end }) => ({
|
|
1615
|
+
? locations.map(({ start, end, strand }) => ({
|
|
1591
1616
|
id,
|
|
1592
1617
|
name: entry.name,
|
|
1593
1618
|
accession: entry.accession,
|
|
1594
1619
|
description: entry.description,
|
|
1620
|
+
featureType: entry.featureType,
|
|
1595
1621
|
start,
|
|
1596
1622
|
end,
|
|
1623
|
+
strand,
|
|
1597
1624
|
}))
|
|
1598
1625
|
: []
|
|
1599
1626
|
}),
|
|
@@ -1625,8 +1652,38 @@ function stateModelFactory() {
|
|
|
1625
1652
|
get verticalScrollbarWidth() {
|
|
1626
1653
|
return self.showVerticalScrollbar ? 20 : 0
|
|
1627
1654
|
},
|
|
1655
|
+
/**
|
|
1656
|
+
* #getter
|
|
1657
|
+
* ordinal segment types (exons etc.), ordered by sequence position so
|
|
1658
|
+
* exon-1..exon-14 read left-to-right; colored by alternating shade and
|
|
1659
|
+
* labeled by number rather than each getting a distinct hue + legend row
|
|
1660
|
+
*/
|
|
1661
|
+
get segmentDomainTypes() {
|
|
1662
|
+
return [...self.tidyInterProAnnotationTypes.values()]
|
|
1663
|
+
.filter(d => segmentFeatureTypes.has(d.featureType ?? ''))
|
|
1664
|
+
.toSorted((a, b) => a.start - b.start)
|
|
1665
|
+
},
|
|
1666
|
+
/**
|
|
1667
|
+
* #getter
|
|
1668
|
+
* categorical feature types (InterPro domains and the like) that each get
|
|
1669
|
+
* their own color and a legend entry
|
|
1670
|
+
*/
|
|
1671
|
+
get categoricalDomainTypes() {
|
|
1672
|
+
return [...self.tidyInterProAnnotationTypes.values()].filter(
|
|
1673
|
+
d => !segmentFeatureTypes.has(d.featureType ?? ''),
|
|
1674
|
+
)
|
|
1675
|
+
},
|
|
1628
1676
|
get fillPalette() {
|
|
1629
|
-
|
|
1677
|
+
const segments = Object.fromEntries(
|
|
1678
|
+
this.segmentDomainTypes.map((d, i) => [
|
|
1679
|
+
d.accession,
|
|
1680
|
+
segmentShades[i % segmentShades.length]!,
|
|
1681
|
+
]),
|
|
1682
|
+
)
|
|
1683
|
+
const categorical = createPaletteMap(
|
|
1684
|
+
this.categoricalDomainTypes.map(d => d.accession),
|
|
1685
|
+
)
|
|
1686
|
+
return { ...segments, ...categorical }
|
|
1630
1687
|
},
|
|
1631
1688
|
get strokePalette() {
|
|
1632
1689
|
return transform(this.fillPalette, ([key, val]) => [
|
|
@@ -1635,6 +1692,57 @@ function stateModelFactory() {
|
|
|
1635
1692
|
])
|
|
1636
1693
|
},
|
|
1637
1694
|
|
|
1695
|
+
/**
|
|
1696
|
+
* #getter
|
|
1697
|
+
* accession -> number drawn on each segment band: the trailing number of
|
|
1698
|
+
* the feature name ("exon-3" -> "3"), else its 1-based position
|
|
1699
|
+
*/
|
|
1700
|
+
get segmentLabels() {
|
|
1701
|
+
return new Map(
|
|
1702
|
+
this.segmentDomainTypes.map((d, i) => {
|
|
1703
|
+
const m = /(\d+)\s*$/.exec(d.name)
|
|
1704
|
+
return [d.accession, m ? m[1]! : `${i + 1}`]
|
|
1705
|
+
}),
|
|
1706
|
+
)
|
|
1707
|
+
},
|
|
1708
|
+
|
|
1709
|
+
/**
|
|
1710
|
+
* #getter
|
|
1711
|
+
* the domain types currently drawn on the alignment (filtered-on), shared
|
|
1712
|
+
* by the on-screen legend and the SVG export legend. Ordinal segments
|
|
1713
|
+
* (exons) are excluded — they read as a numbered gene model, not a color
|
|
1714
|
+
* key — so this is the categorical types ordered by sequence position
|
|
1715
|
+
*/
|
|
1716
|
+
get visibleDomainTypes() {
|
|
1717
|
+
return this.categoricalDomainTypes
|
|
1718
|
+
.filter(d => self.featureFilters.get(d.accession))
|
|
1719
|
+
.toSorted((a, b) => a.start - b.start)
|
|
1720
|
+
},
|
|
1721
|
+
|
|
1722
|
+
/**
|
|
1723
|
+
* #getter
|
|
1724
|
+
* domain annotations under the mouse, hit-tested against the exact visible
|
|
1725
|
+
* column span each box is drawn at (so it matches the overlay across gaps)
|
|
1726
|
+
*/
|
|
1727
|
+
get mouseOverDomains() {
|
|
1728
|
+
const { mouseCol } = self
|
|
1729
|
+
const name = self.mouseOverRowName
|
|
1730
|
+
if (name !== undefined && mouseCol !== undefined) {
|
|
1731
|
+
const entry = self.tidyFilteredGatheredInterProAnnotations[name] ?? []
|
|
1732
|
+
return entry.filter(d => {
|
|
1733
|
+
const m1 = self.seqPosToVisibleCol(name, d.start - 1)
|
|
1734
|
+
const m2 = self.seqPosToVisibleCol(name, d.end)
|
|
1735
|
+
return (
|
|
1736
|
+
m1 !== undefined &&
|
|
1737
|
+
m2 !== undefined &&
|
|
1738
|
+
mouseCol >= m1 &&
|
|
1739
|
+
mouseCol < m2
|
|
1740
|
+
)
|
|
1741
|
+
})
|
|
1742
|
+
}
|
|
1743
|
+
return []
|
|
1744
|
+
},
|
|
1745
|
+
|
|
1638
1746
|
/**
|
|
1639
1747
|
* #method
|
|
1640
1748
|
*/
|
|
@@ -1703,22 +1811,8 @@ function stateModelFactory() {
|
|
|
1703
1811
|
* #action
|
|
1704
1812
|
*/
|
|
1705
1813
|
fit() {
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
self.msaAreaHeight / self.numRows,
|
|
1709
|
-
minRowHeight,
|
|
1710
|
-
maxCellSize,
|
|
1711
|
-
)
|
|
1712
|
-
}
|
|
1713
|
-
if (self.numColumns > 0) {
|
|
1714
|
-
self.colWidth = clamp(
|
|
1715
|
-
self.msaAreaWidth / self.numColumns,
|
|
1716
|
-
minColWidth,
|
|
1717
|
-
maxCellSize,
|
|
1718
|
-
)
|
|
1719
|
-
}
|
|
1720
|
-
self.scrollX = 0
|
|
1721
|
-
self.scrollY = 0
|
|
1814
|
+
this.fitVertically()
|
|
1815
|
+
this.fitHorizontally()
|
|
1722
1816
|
},
|
|
1723
1817
|
/**
|
|
1724
1818
|
* #action
|
|
@@ -1748,6 +1842,13 @@ function stateModelFactory() {
|
|
|
1748
1842
|
},
|
|
1749
1843
|
|
|
1750
1844
|
afterCreate() {
|
|
1845
|
+
// seed the highlighted-columns overlay from the declarative property so
|
|
1846
|
+
// a shared snapshot/URL opens with those columns highlighted (the
|
|
1847
|
+
// volatile highlightedColumns can later be driven by genome-hover sync)
|
|
1848
|
+
if (self.highlightColumns?.length) {
|
|
1849
|
+
self.setHighlightedColumns(self.highlightColumns)
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1751
1852
|
addDisposer(
|
|
1752
1853
|
self,
|
|
1753
1854
|
autorun(() => {
|
package/src/neighborJoining.ts
CHANGED
|
@@ -194,16 +194,10 @@ function neighborJoining(distances: number[][], names: string[]): NJNode {
|
|
|
194
194
|
const ri = r.get(minI)!
|
|
195
195
|
const rj = r.get(minJ)!
|
|
196
196
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
limbI = dij / 2 + (ri - rj) / (2 * (remaining - 2))
|
|
202
|
-
limbJ = dij - limbI
|
|
203
|
-
} else {
|
|
204
|
-
limbI = dij / 2
|
|
205
|
-
limbJ = dij / 2
|
|
206
|
-
}
|
|
197
|
+
// remaining is always > 2 inside this loop; the final two nodes are
|
|
198
|
+
// connected after it
|
|
199
|
+
let limbI = dij / 2 + (ri - rj) / (2 * (remaining - 2))
|
|
200
|
+
let limbJ = dij - limbI
|
|
207
201
|
|
|
208
202
|
// Ensure non-negative branch lengths
|
|
209
203
|
limbI = Math.max(0, limbI)
|
|
@@ -217,19 +211,18 @@ function neighborJoining(distances: number[][], names: string[]): NJNode {
|
|
|
217
211
|
rightLength: limbJ,
|
|
218
212
|
}
|
|
219
213
|
|
|
220
|
-
// Update distance matrix
|
|
221
|
-
const newIdx = minI
|
|
214
|
+
// Update distance matrix, reusing minI's slot for the merged node
|
|
222
215
|
for (const k of active) {
|
|
223
216
|
if (k !== minI && k !== minJ) {
|
|
224
217
|
const newDist = (D[minI]![k]! + D[minJ]![k]! - dij) / 2
|
|
225
|
-
D[
|
|
226
|
-
D[k]![
|
|
218
|
+
D[minI]![k] = Math.max(0, newDist)
|
|
219
|
+
D[k]![minI] = Math.max(0, newDist)
|
|
227
220
|
}
|
|
228
221
|
}
|
|
229
222
|
|
|
230
223
|
// Mark minJ as removed
|
|
231
224
|
nodes[minJ] = undefined
|
|
232
|
-
nodes[
|
|
225
|
+
nodes[minI] = newNode
|
|
233
226
|
|
|
234
227
|
remaining--
|
|
235
228
|
}
|
|
@@ -257,25 +250,45 @@ function neighborJoining(distances: number[][], names: string[]): NJNode {
|
|
|
257
250
|
return nodes[finalActive[0]!]!
|
|
258
251
|
}
|
|
259
252
|
|
|
260
|
-
function
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
result = `'${quotedName}'`
|
|
266
|
-
} else {
|
|
267
|
-
const leftNewick = node.left ? nodeToNewick(node.left, node.leftLength) : ''
|
|
268
|
-
const rightNewick = node.right
|
|
269
|
-
? nodeToNewick(node.right, node.rightLength)
|
|
270
|
-
: ''
|
|
271
|
-
result = `(${leftNewick},${rightNewick})`
|
|
272
|
-
}
|
|
253
|
+
function withBranchLength(newick: string, branchLength?: number) {
|
|
254
|
+
return branchLength === undefined
|
|
255
|
+
? newick
|
|
256
|
+
: `${newick}:${branchLength.toFixed(6)}`
|
|
257
|
+
}
|
|
273
258
|
|
|
274
|
-
|
|
275
|
-
|
|
259
|
+
// Emit Newick iteratively rather than recursively: an NJ tree of N leaves can
|
|
260
|
+
// be a caterpillar of depth ~N, so recursion risks a stack overflow on large
|
|
261
|
+
// alignments (matching the iterative-traversal convention in hierarchy.ts).
|
|
262
|
+
function nodeToNewick(root: NJNode): string {
|
|
263
|
+
const postOrder: NJNode[] = []
|
|
264
|
+
const stack: NJNode[] = [root]
|
|
265
|
+
while (stack.length > 0) {
|
|
266
|
+
const node = stack.pop()!
|
|
267
|
+
postOrder.push(node)
|
|
268
|
+
if (node.left) {
|
|
269
|
+
stack.push(node.left)
|
|
270
|
+
}
|
|
271
|
+
if (node.right) {
|
|
272
|
+
stack.push(node.right)
|
|
273
|
+
}
|
|
276
274
|
}
|
|
277
275
|
|
|
278
|
-
|
|
276
|
+
const newickByNode = new Map<NJNode, string>()
|
|
277
|
+
for (let i = postOrder.length - 1; i >= 0; i--) {
|
|
278
|
+
const node = postOrder[i]!
|
|
279
|
+
if (node.name !== undefined && !node.left && !node.right) {
|
|
280
|
+
newickByNode.set(node, `'${node.name.replaceAll("'", "''")}'`)
|
|
281
|
+
} else {
|
|
282
|
+
const left = node.left
|
|
283
|
+
? withBranchLength(newickByNode.get(node.left)!, node.leftLength)
|
|
284
|
+
: ''
|
|
285
|
+
const right = node.right
|
|
286
|
+
? withBranchLength(newickByNode.get(node.right)!, node.rightLength)
|
|
287
|
+
: ''
|
|
288
|
+
newickByNode.set(node, `(${left},${right})`)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return newickByNode.get(root)!
|
|
279
292
|
}
|
|
280
293
|
|
|
281
294
|
export function calculateNeighborJoiningTree(
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
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).
|
|
6
|
+
import { createJBrowseTheme } from '@jbrowse/core/ui/theme'
|
|
7
|
+
import { enableStaticRendering } from 'mobx-react'
|
|
8
|
+
import { beforeAll, expect, test } from 'vitest'
|
|
9
|
+
|
|
10
|
+
import MSAModelF from './model.ts'
|
|
11
|
+
import { renderToSvg } from './renderToSvg.tsx'
|
|
12
|
+
|
|
13
|
+
class Mat {
|
|
14
|
+
constructor(
|
|
15
|
+
public a = 1,
|
|
16
|
+
public b = 0,
|
|
17
|
+
public c = 0,
|
|
18
|
+
public d = 1,
|
|
19
|
+
public e = 0,
|
|
20
|
+
public f = 0,
|
|
21
|
+
) {}
|
|
22
|
+
multiply(o: Mat) {
|
|
23
|
+
return new Mat(
|
|
24
|
+
this.a * o.a + this.c * o.b,
|
|
25
|
+
this.b * o.a + this.d * o.b,
|
|
26
|
+
this.a * o.c + this.c * o.d,
|
|
27
|
+
this.b * o.c + this.d * o.d,
|
|
28
|
+
this.a * o.e + this.c * o.f + this.e,
|
|
29
|
+
this.b * o.e + this.d * o.f + this.f,
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
translate(x: number, y = 0) {
|
|
33
|
+
return this.multiply(new Mat(1, 0, 0, 1, x, y))
|
|
34
|
+
}
|
|
35
|
+
scale(x: number, y = x) {
|
|
36
|
+
return this.multiply(new Mat(x, 0, 0, y, 0, 0))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
class Pt {
|
|
40
|
+
constructor(
|
|
41
|
+
public x = 0,
|
|
42
|
+
public y = 0,
|
|
43
|
+
) {}
|
|
44
|
+
matrixTransform(m: Mat) {
|
|
45
|
+
return new Pt(
|
|
46
|
+
m.a * this.x + m.c * this.y + m.e,
|
|
47
|
+
m.b * this.x + m.d * this.y + m.f,
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeAll(() => {
|
|
53
|
+
enableStaticRendering(true)
|
|
54
|
+
const g = globalThis as Record<string, unknown>
|
|
55
|
+
g.DOMMatrix = Mat
|
|
56
|
+
g.DOMPoint = Pt
|
|
57
|
+
HTMLCanvasElement.prototype.getContext = function () {
|
|
58
|
+
let font = '10px sans-serif'
|
|
59
|
+
return {
|
|
60
|
+
get font() {
|
|
61
|
+
return font
|
|
62
|
+
},
|
|
63
|
+
set font(v: string) {
|
|
64
|
+
font = v
|
|
65
|
+
},
|
|
66
|
+
measureText(t: string) {
|
|
67
|
+
const size = Number.parseFloat(font) || 10
|
|
68
|
+
return { width: t.length * size * 0.6 } as TextMetrics
|
|
69
|
+
},
|
|
70
|
+
} as unknown as CanvasRenderingContext2D
|
|
71
|
+
} as unknown as typeof HTMLCanvasElement.prototype.getContext
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const msa = `>seq1
|
|
75
|
+
ACDEFGHIKL
|
|
76
|
+
>seq2
|
|
77
|
+
ACDE-GHIKL`
|
|
78
|
+
|
|
79
|
+
function makeModel() {
|
|
80
|
+
const model = MSAModelF().create({
|
|
81
|
+
id: 'domain-legend-test',
|
|
82
|
+
type: 'MsaView',
|
|
83
|
+
height: 400,
|
|
84
|
+
msaFormat: 'fasta',
|
|
85
|
+
data: { msa },
|
|
86
|
+
})
|
|
87
|
+
model.setWidth(800)
|
|
88
|
+
model.setDomains({
|
|
89
|
+
seq1: {
|
|
90
|
+
xref: [{ id: 'seq1' }],
|
|
91
|
+
matches: [
|
|
92
|
+
{
|
|
93
|
+
signature: {
|
|
94
|
+
entry: {
|
|
95
|
+
name: 'Kinase',
|
|
96
|
+
accession: 'PF00069',
|
|
97
|
+
description: 'Protein kinase domain',
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
locations: [{ start: 1, end: 5 }],
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
return model
|
|
106
|
+
}
|
|
107
|
+
|
|
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, {
|
|
114
|
+
theme: createJBrowseTheme(),
|
|
115
|
+
exportType: 'entire',
|
|
116
|
+
includeMinimap: false,
|
|
117
|
+
includeTracks: false,
|
|
118
|
+
})
|
|
119
|
+
expect(svg).toContain('Kinase')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('no legend column when domains are hidden', async () => {
|
|
123
|
+
const model = makeModel()
|
|
124
|
+
model.setShowDomains(false)
|
|
125
|
+
expect(model.actuallyShowDomains).toBe(false)
|
|
126
|
+
|
|
127
|
+
const svg = await renderToSvg(model, {
|
|
128
|
+
theme: createJBrowseTheme(),
|
|
129
|
+
exportType: 'entire',
|
|
130
|
+
includeMinimap: false,
|
|
131
|
+
includeTracks: false,
|
|
132
|
+
})
|
|
133
|
+
expect(svg).not.toContain('Kinase')
|
|
134
|
+
})
|