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.
@@ -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 = { label, color }
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.0'
1
+ export const version = '8.2.0'