react-msaview 8.1.0 → 8.2.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/bundle/index.js +40 -40
- package/bundle/index.js.map +3 -3
- package/dist/expandSpec.d.ts +46 -0
- package/dist/expandSpec.js +127 -0
- package/dist/expandSpec.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/model.d.ts +21 -1
- package/dist/model.js +48 -1
- package/dist/model.js.map +1 -1
- package/dist/svgTestUtil.d.ts +10 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/src/expandSpec.ts +196 -0
- package/src/index.ts +8 -0
- package/src/model.ts +57 -1
- package/src/version.ts +1 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { ColumnTrackSpec, Highlight, Region } from './types.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `row: null` opts one entry out of the spec's `query` row, back to alignment
|
|
5
|
+
* columns.
|
|
6
|
+
*/
|
|
7
|
+
type RowChoice = { row?: string | null }
|
|
8
|
+
|
|
9
|
+
export type HighlightShorthand =
|
|
10
|
+
| number
|
|
11
|
+
| string
|
|
12
|
+
| (Omit<Highlight, 'row'> & RowChoice)
|
|
13
|
+
|
|
14
|
+
export type RegionShorthand = string | (Omit<Region, 'row'> & RowChoice)
|
|
15
|
+
|
|
16
|
+
export type ColumnTrackShorthand = Omit<
|
|
17
|
+
ColumnTrackSpec,
|
|
18
|
+
'id' | 'kind' | 'row'
|
|
19
|
+
> &
|
|
20
|
+
RowChoice & {
|
|
21
|
+
id?: string
|
|
22
|
+
kind?: ColumnTrackSpec['kind']
|
|
23
|
+
/** the 1-based position `values` or `data` begins at */
|
|
24
|
+
start?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The short forms a hand-written or generated link can use. Every long form
|
|
29
|
+
* passes through unchanged, so a snapshot is also a valid spec.
|
|
30
|
+
*/
|
|
31
|
+
export interface MsaSpec {
|
|
32
|
+
/**
|
|
33
|
+
* The row the spec is about: sets `relativeTo`, and is the `row` of every
|
|
34
|
+
* highlight, region and column track that names none
|
|
35
|
+
*/
|
|
36
|
+
query?: string
|
|
37
|
+
/** a url, or the alignment itself when it spans more than one line */
|
|
38
|
+
msa?: string
|
|
39
|
+
/** a url, or a newick string when it starts with "(" */
|
|
40
|
+
tree?: string
|
|
41
|
+
highlights?: HighlightShorthand[]
|
|
42
|
+
region?: RegionShorthand
|
|
43
|
+
columnTracks?: ColumnTrackShorthand[]
|
|
44
|
+
[key: string]: unknown
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The label a single-residue shorthand highlight gets, filled in from the
|
|
49
|
+
* sequence when drawn: `175` on a row reading R there becomes "R175".
|
|
50
|
+
*/
|
|
51
|
+
export const residueLabel = '{residue}{position}'
|
|
52
|
+
|
|
53
|
+
const span = /^(\d+)(?:-(\d+))?(?:\s+(.+))?$/
|
|
54
|
+
|
|
55
|
+
function parseSpan(text: string, what: string) {
|
|
56
|
+
const match = span.exec(text.trim())
|
|
57
|
+
if (!match) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`${what} "${text}" is not "start", "start-end" or either followed by a label`,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
const start = Number(match[1])
|
|
63
|
+
const end = match[2] === undefined ? start : Number(match[2])
|
|
64
|
+
return { start, end, label: match[3] }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function withRow<T extends RowChoice>(entry: T, query?: string) {
|
|
68
|
+
const { row, ...rest } = entry
|
|
69
|
+
const resolved = row === undefined ? query : (row ?? undefined)
|
|
70
|
+
return resolved === undefined ? rest : { ...rest, row: resolved }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function expandHighlight(entry: HighlightShorthand, query?: string) {
|
|
74
|
+
if (typeof entry === 'object') {
|
|
75
|
+
return (
|
|
76
|
+
entry.rows
|
|
77
|
+
? entry
|
|
78
|
+
: withRow(entry, entry.start === undefined ? undefined : query)
|
|
79
|
+
) as Highlight
|
|
80
|
+
}
|
|
81
|
+
const { start, end, label } = parseSpan(String(entry), 'highlight')
|
|
82
|
+
const single = start === end && query !== undefined
|
|
83
|
+
return {
|
|
84
|
+
...(query === undefined ? {} : { row: query }),
|
|
85
|
+
start,
|
|
86
|
+
end,
|
|
87
|
+
...(label ? { label } : single ? { label: residueLabel } : {}),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function expandRegion(region: RegionShorthand, query?: string) {
|
|
92
|
+
if (typeof region === 'string') {
|
|
93
|
+
const { start, end } = parseSpan(region, 'region')
|
|
94
|
+
const parsed: Omit<Region, 'row'> & RowChoice = { start, end }
|
|
95
|
+
return withRow(parsed, query) as Region
|
|
96
|
+
}
|
|
97
|
+
return withRow(region, query) as Region
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function slug(name: string) {
|
|
101
|
+
return name
|
|
102
|
+
.toLowerCase()
|
|
103
|
+
.replaceAll(/[^a-z0-9]+/g, '-')
|
|
104
|
+
.replaceAll(/^-|-$/g, '')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function kindOf(track: ColumnTrackShorthand) {
|
|
108
|
+
if (track.kind) {
|
|
109
|
+
return track.kind
|
|
110
|
+
}
|
|
111
|
+
if (track.values) {
|
|
112
|
+
return 'bar'
|
|
113
|
+
}
|
|
114
|
+
if (track.data !== undefined) {
|
|
115
|
+
return 'text'
|
|
116
|
+
}
|
|
117
|
+
if (track.arcs) {
|
|
118
|
+
return 'arc'
|
|
119
|
+
}
|
|
120
|
+
throw new Error(
|
|
121
|
+
`column track "${track.name}" has no values, data or arcs to take its kind from`,
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function expandTrack(
|
|
126
|
+
track: ColumnTrackShorthand,
|
|
127
|
+
query: string | undefined,
|
|
128
|
+
ids: Set<string>,
|
|
129
|
+
): ColumnTrackSpec {
|
|
130
|
+
const { start, ...rest } = track
|
|
131
|
+
const pad = start && start > 1 ? start - 1 : 0
|
|
132
|
+
let id = track.id ?? (slug(track.name) || 'track')
|
|
133
|
+
for (let n = 2; !track.id && ids.has(id); n++) {
|
|
134
|
+
id = `${slug(track.name) || 'track'}-${n}`
|
|
135
|
+
}
|
|
136
|
+
ids.add(id)
|
|
137
|
+
return {
|
|
138
|
+
...(withRow(rest, query) as Omit<ColumnTrackSpec, 'id' | 'kind'>),
|
|
139
|
+
id,
|
|
140
|
+
kind: kindOf(track),
|
|
141
|
+
...(pad && track.values
|
|
142
|
+
? { values: [...Array<number>(pad).fill(0), ...track.values] }
|
|
143
|
+
: {}),
|
|
144
|
+
...(pad && track.data !== undefined
|
|
145
|
+
? { data: ' '.repeat(pad) + track.data }
|
|
146
|
+
: {}),
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isInlineMsa(msa: string) {
|
|
151
|
+
return msa.includes('\n')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isInlineTree(tree: string) {
|
|
155
|
+
return tree.trimStart().startsWith('(')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function uri(location: string) {
|
|
159
|
+
return { uri: location, locationType: 'UriLocation' as const }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Expand the short forms of an MsaView spec into the snapshot the model takes.
|
|
164
|
+
* Idempotent, and a no-op on a spec that uses none of them.
|
|
165
|
+
*/
|
|
166
|
+
export function expandSpec(spec: MsaSpec): Record<string, unknown> {
|
|
167
|
+
const { query, msa, tree, highlights, region, columnTracks, ...rest } = spec
|
|
168
|
+
const data = {
|
|
169
|
+
...(rest.data as Record<string, unknown> | undefined),
|
|
170
|
+
...(msa !== undefined && isInlineMsa(msa) ? { msa } : {}),
|
|
171
|
+
...(tree !== undefined && isInlineTree(tree) ? { tree } : {}),
|
|
172
|
+
}
|
|
173
|
+
const ids = new Set(
|
|
174
|
+
(columnTracks ?? []).map(t => t.id).filter(id => id !== undefined),
|
|
175
|
+
)
|
|
176
|
+
return {
|
|
177
|
+
...rest,
|
|
178
|
+
...(msa !== undefined && !isInlineMsa(msa)
|
|
179
|
+
? { msaFilehandle: uri(msa) }
|
|
180
|
+
: {}),
|
|
181
|
+
...(tree !== undefined && !isInlineTree(tree)
|
|
182
|
+
? { treeFilehandle: uri(tree) }
|
|
183
|
+
: {}),
|
|
184
|
+
...(Object.keys(data).length > 0 ? { data } : {}),
|
|
185
|
+
...(query !== undefined && rest.relativeTo === undefined
|
|
186
|
+
? { relativeTo: query }
|
|
187
|
+
: {}),
|
|
188
|
+
...(highlights
|
|
189
|
+
? { highlights: highlights.map(h => expandHighlight(h, query)) }
|
|
190
|
+
: {}),
|
|
191
|
+
...(region === undefined ? {} : { region: expandRegion(region, query) }),
|
|
192
|
+
...(columnTracks
|
|
193
|
+
? { columnTracks: columnTracks.map(t => expandTrack(t, query, ids)) }
|
|
194
|
+
: {}),
|
|
195
|
+
}
|
|
196
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,14 @@ export {
|
|
|
18
18
|
// the row count above which calculateNeighborJoiningTreeFromMSA throws, for
|
|
19
19
|
// hosts gating their own menu item
|
|
20
20
|
export { maxNeighborJoiningRows } from './constants.ts'
|
|
21
|
+
// the short forms a link or a script can write a view in; see docs/layers.md
|
|
22
|
+
export { expandSpec, residueLabel } from './expandSpec.ts'
|
|
23
|
+
export type {
|
|
24
|
+
ColumnTrackShorthand,
|
|
25
|
+
HighlightShorthand,
|
|
26
|
+
MsaSpec,
|
|
27
|
+
RegionShorthand,
|
|
28
|
+
} from './expandSpec.ts'
|
|
21
29
|
export { default as MSAView } from './components/Loading.tsx'
|
|
22
30
|
export { default as MSAViewer } from './components/MSAViewer.tsx'
|
|
23
31
|
export type { MSAViewerProps } from './components/MSAViewer.tsx'
|
package/src/model.ts
CHANGED
|
@@ -877,6 +877,14 @@ function stateModelFactory() {
|
|
|
877
877
|
* Persists in the snapshot and the URL.
|
|
878
878
|
*/
|
|
879
879
|
highlights: stripDefault(types.array(types.frozen<Highlight>()), []),
|
|
880
|
+
/**
|
|
881
|
+
* #property
|
|
882
|
+
* where the view opens, in `highlights` coordinates: `{row, start,
|
|
883
|
+
* end}` zooms onto residues of that row, `{start, end}` onto alignment
|
|
884
|
+
* columns. Applied once the alignment and any tree file have loaded,
|
|
885
|
+
* then cleared, so a reloaded session keeps the reader's own scroll.
|
|
886
|
+
*/
|
|
887
|
+
region: types.frozen<Region | undefined>(),
|
|
880
888
|
/**
|
|
881
889
|
* #property
|
|
882
890
|
* clades of the tree with a mark drawn over them. `mrca` names tips
|
|
@@ -1156,6 +1164,12 @@ function stateModelFactory() {
|
|
|
1156
1164
|
setHighlightedColumns(columns?: number[]) {
|
|
1157
1165
|
self.highlightedColumns = columns
|
|
1158
1166
|
},
|
|
1167
|
+
/**
|
|
1168
|
+
* #action
|
|
1169
|
+
*/
|
|
1170
|
+
setRegion(region?: Region) {
|
|
1171
|
+
self.region = region
|
|
1172
|
+
},
|
|
1159
1173
|
/**
|
|
1160
1174
|
* #action
|
|
1161
1175
|
*/
|
|
@@ -3621,6 +3635,26 @@ function stateModelFactory() {
|
|
|
3621
3635
|
return runs
|
|
3622
3636
|
},
|
|
3623
3637
|
|
|
3638
|
+
/**
|
|
3639
|
+
* #method
|
|
3640
|
+
* a highlight label with `{residue}` and `{position}` filled in from the
|
|
3641
|
+
* row's letter at `start`, which is how the `175` shorthand draws "R175"
|
|
3642
|
+
*/
|
|
3643
|
+
fillHighlightLabel(label: string, row?: string, start?: number) {
|
|
3644
|
+
if (!label.includes('{')) {
|
|
3645
|
+
return label
|
|
3646
|
+
}
|
|
3647
|
+
const col =
|
|
3648
|
+
row && start !== undefined
|
|
3649
|
+
? self.seqPosIndex(row)?.[start - 1]
|
|
3650
|
+
: undefined
|
|
3651
|
+
const residue =
|
|
3652
|
+
row && col !== undefined ? (self.MSA?.getRow(row)[col] ?? '') : ''
|
|
3653
|
+
return label
|
|
3654
|
+
.replaceAll('{residue}', residue)
|
|
3655
|
+
.replaceAll('{position}', start === undefined ? '' : String(start))
|
|
3656
|
+
},
|
|
3657
|
+
|
|
3624
3658
|
/**
|
|
3625
3659
|
* #getter
|
|
3626
3660
|
* `highlights` projected onto what is on screen: residue spans go
|
|
@@ -3636,7 +3670,10 @@ function stateModelFactory() {
|
|
|
3636
3670
|
...Object.values(transientHighlights).flat(),
|
|
3637
3671
|
]
|
|
3638
3672
|
return all.flatMap(({ row, rows, start, end, label, color }) => {
|
|
3639
|
-
const base = {
|
|
3673
|
+
const base = {
|
|
3674
|
+
label: label && this.fillHighlightLabel(label, row, start),
|
|
3675
|
+
color,
|
|
3676
|
+
}
|
|
3640
3677
|
if (rows) {
|
|
3641
3678
|
const rowIndices = rows
|
|
3642
3679
|
.map(name => rowNamesSet.get(name))
|
|
@@ -4081,6 +4118,25 @@ function stateModelFactory() {
|
|
|
4081
4118
|
}),
|
|
4082
4119
|
)
|
|
4083
4120
|
|
|
4121
|
+
// zoomToRegion needs a width and resolves residues through the visible
|
|
4122
|
+
// columns, so this waits for both, and for a tree file whose collapsed
|
|
4123
|
+
// clades would hide columns and move the target after the zoom
|
|
4124
|
+
addDisposer(
|
|
4125
|
+
self,
|
|
4126
|
+
autorun(() => {
|
|
4127
|
+
const { region } = self
|
|
4128
|
+
if (
|
|
4129
|
+
region &&
|
|
4130
|
+
self.viewInitialized &&
|
|
4131
|
+
self.numColumns > 0 &&
|
|
4132
|
+
!(self.treeFilehandle && !self.data.tree)
|
|
4133
|
+
) {
|
|
4134
|
+
this.zoomToRegion(region)
|
|
4135
|
+
self.setRegion(undefined)
|
|
4136
|
+
}
|
|
4137
|
+
}),
|
|
4138
|
+
)
|
|
4139
|
+
|
|
4084
4140
|
// the matchMedia query is pinned to the current device pixel ratio, so
|
|
4085
4141
|
// each change re-registers against the new one
|
|
4086
4142
|
if (
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = '8.
|
|
1
|
+
export const version = '8.2.0'
|