p2s_mapper 1.0.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.
Files changed (172) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -0
  3. package/dist/alignmentQuality.d.ts +58 -0
  4. package/dist/alignmentQuality.js +108 -0
  5. package/dist/alignmentQuality.js.map +1 -0
  6. package/dist/alphaFoldModels.d.ts +29 -0
  7. package/dist/alphaFoldModels.js +60 -0
  8. package/dist/alphaFoldModels.js.map +1 -0
  9. package/dist/caCoordsToPdb.d.ts +18 -0
  10. package/dist/caCoordsToPdb.js +95 -0
  11. package/dist/caCoordsToPdb.js.map +1 -0
  12. package/dist/chooseMappedEntity.d.ts +55 -0
  13. package/dist/chooseMappedEntity.js +140 -0
  14. package/dist/chooseMappedEntity.js.map +1 -0
  15. package/dist/coordinates.d.ts +66 -0
  16. package/dist/coordinates.js +66 -0
  17. package/dist/coordinates.js.map +1 -0
  18. package/dist/dbIds.d.ts +16 -0
  19. package/dist/dbIds.js +42 -0
  20. package/dist/dbIds.js.map +1 -0
  21. package/dist/extractPerResidueConfidence.d.ts +49 -0
  22. package/dist/extractPerResidueConfidence.js +42 -0
  23. package/dist/extractPerResidueConfidence.js.map +1 -0
  24. package/dist/extractStructureSequences.d.ts +142 -0
  25. package/dist/extractStructureSequences.js +217 -0
  26. package/dist/extractStructureSequences.js.map +1 -0
  27. package/dist/fetchUtils.d.ts +34 -0
  28. package/dist/fetchUtils.js +91 -0
  29. package/dist/fetchUtils.js.map +1 -0
  30. package/dist/index.d.ts +23 -0
  31. package/dist/index.js +46 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/invertMap.d.ts +2 -0
  34. package/dist/invertMap.js +8 -0
  35. package/dist/invertMap.js.map +1 -0
  36. package/dist/isoformRanking.d.ts +47 -0
  37. package/dist/isoformRanking.js +91 -0
  38. package/dist/isoformRanking.js.map +1 -0
  39. package/dist/mappings.d.ts +58 -0
  40. package/dist/mappings.js +193 -0
  41. package/dist/mappings.js.map +1 -0
  42. package/dist/package.json +1 -0
  43. package/dist/pairwiseAlignment.d.ts +35 -0
  44. package/dist/pairwiseAlignment.js +337 -0
  45. package/dist/pairwiseAlignment.js.map +1 -0
  46. package/dist/pdbUniProtMapping.d.ts +75 -0
  47. package/dist/pdbUniProtMapping.js +241 -0
  48. package/dist/pdbUniProtMapping.js.map +1 -0
  49. package/dist/pdbeBestStructures.d.ts +23 -0
  50. package/dist/pdbeBestStructures.js +79 -0
  51. package/dist/pdbeBestStructures.js.map +1 -0
  52. package/dist/pdbeEntryMolecules.d.ts +7 -0
  53. package/dist/pdbeEntryMolecules.js +39 -0
  54. package/dist/pdbeEntryMolecules.js.map +1 -0
  55. package/dist/siftsNumbering.d.ts +23 -0
  56. package/dist/siftsNumbering.js +44 -0
  57. package/dist/siftsNumbering.js.map +1 -0
  58. package/dist/stripStopCodon.d.ts +15 -0
  59. package/dist/stripStopCodon.js +24 -0
  60. package/dist/stripStopCodon.js.map +1 -0
  61. package/dist/structureFormat.d.ts +40 -0
  62. package/dist/structureFormat.js +54 -0
  63. package/dist/structureFormat.js.map +1 -0
  64. package/dist/structureSources.d.ts +28 -0
  65. package/dist/structureSources.js +76 -0
  66. package/dist/structureSources.js.map +1 -0
  67. package/dist/structureUrls.d.ts +54 -0
  68. package/dist/structureUrls.js +147 -0
  69. package/dist/structureUrls.js.map +1 -0
  70. package/dist/types.d.ts +14 -0
  71. package/dist/types.js +27 -0
  72. package/dist/types.js.map +1 -0
  73. package/dist/uniProtSearch.d.ts +32 -0
  74. package/dist/uniProtSearch.js +114 -0
  75. package/dist/uniProtSearch.js.map +1 -0
  76. package/esm/alignmentQuality.d.ts +58 -0
  77. package/esm/alignmentQuality.js +100 -0
  78. package/esm/alignmentQuality.js.map +1 -0
  79. package/esm/alphaFoldModels.d.ts +29 -0
  80. package/esm/alphaFoldModels.js +55 -0
  81. package/esm/alphaFoldModels.js.map +1 -0
  82. package/esm/caCoordsToPdb.d.ts +18 -0
  83. package/esm/caCoordsToPdb.js +91 -0
  84. package/esm/caCoordsToPdb.js.map +1 -0
  85. package/esm/chooseMappedEntity.d.ts +55 -0
  86. package/esm/chooseMappedEntity.js +134 -0
  87. package/esm/chooseMappedEntity.js.map +1 -0
  88. package/esm/coordinates.d.ts +66 -0
  89. package/esm/coordinates.js +58 -0
  90. package/esm/coordinates.js.map +1 -0
  91. package/esm/dbIds.d.ts +16 -0
  92. package/esm/dbIds.js +35 -0
  93. package/esm/dbIds.js.map +1 -0
  94. package/esm/extractPerResidueConfidence.d.ts +49 -0
  95. package/esm/extractPerResidueConfidence.js +38 -0
  96. package/esm/extractPerResidueConfidence.js.map +1 -0
  97. package/esm/extractStructureSequences.d.ts +142 -0
  98. package/esm/extractStructureSequences.js +205 -0
  99. package/esm/extractStructureSequences.js.map +1 -0
  100. package/esm/fetchUtils.d.ts +34 -0
  101. package/esm/fetchUtils.js +82 -0
  102. package/esm/fetchUtils.js.map +1 -0
  103. package/esm/index.d.ts +23 -0
  104. package/esm/index.js +30 -0
  105. package/esm/index.js.map +1 -0
  106. package/esm/invertMap.d.ts +2 -0
  107. package/esm/invertMap.js +5 -0
  108. package/esm/invertMap.js.map +1 -0
  109. package/esm/isoformRanking.d.ts +47 -0
  110. package/esm/isoformRanking.js +86 -0
  111. package/esm/isoformRanking.js.map +1 -0
  112. package/esm/mappings.d.ts +58 -0
  113. package/esm/mappings.js +179 -0
  114. package/esm/mappings.js.map +1 -0
  115. package/esm/pairwiseAlignment.d.ts +35 -0
  116. package/esm/pairwiseAlignment.js +328 -0
  117. package/esm/pairwiseAlignment.js.map +1 -0
  118. package/esm/pdbUniProtMapping.d.ts +75 -0
  119. package/esm/pdbUniProtMapping.js +231 -0
  120. package/esm/pdbUniProtMapping.js.map +1 -0
  121. package/esm/pdbeBestStructures.d.ts +23 -0
  122. package/esm/pdbeBestStructures.js +73 -0
  123. package/esm/pdbeBestStructures.js.map +1 -0
  124. package/esm/pdbeEntryMolecules.d.ts +7 -0
  125. package/esm/pdbeEntryMolecules.js +35 -0
  126. package/esm/pdbeEntryMolecules.js.map +1 -0
  127. package/esm/siftsNumbering.d.ts +23 -0
  128. package/esm/siftsNumbering.js +40 -0
  129. package/esm/siftsNumbering.js.map +1 -0
  130. package/esm/stripStopCodon.d.ts +15 -0
  131. package/esm/stripStopCodon.js +20 -0
  132. package/esm/stripStopCodon.js.map +1 -0
  133. package/esm/structureFormat.d.ts +40 -0
  134. package/esm/structureFormat.js +48 -0
  135. package/esm/structureFormat.js.map +1 -0
  136. package/esm/structureSources.d.ts +28 -0
  137. package/esm/structureSources.js +71 -0
  138. package/esm/structureSources.js.map +1 -0
  139. package/esm/structureUrls.d.ts +54 -0
  140. package/esm/structureUrls.js +134 -0
  141. package/esm/structureUrls.js.map +1 -0
  142. package/esm/types.d.ts +14 -0
  143. package/esm/types.js +23 -0
  144. package/esm/types.js.map +1 -0
  145. package/esm/uniProtSearch.d.ts +32 -0
  146. package/esm/uniProtSearch.js +110 -0
  147. package/esm/uniProtSearch.js.map +1 -0
  148. package/package.json +63 -0
  149. package/src/alignmentQuality.ts +132 -0
  150. package/src/alphaFoldModels.ts +91 -0
  151. package/src/caCoordsToPdb.ts +111 -0
  152. package/src/chooseMappedEntity.ts +198 -0
  153. package/src/coordinates.ts +119 -0
  154. package/src/dbIds.ts +46 -0
  155. package/src/extractPerResidueConfidence.ts +88 -0
  156. package/src/extractStructureSequences.ts +342 -0
  157. package/src/fetchUtils.ts +110 -0
  158. package/src/index.ts +34 -0
  159. package/src/invertMap.ts +4 -0
  160. package/src/isoformRanking.ts +139 -0
  161. package/src/mappings.ts +230 -0
  162. package/src/pairwiseAlignment.ts +423 -0
  163. package/src/pdbUniProtMapping.ts +320 -0
  164. package/src/pdbeBestStructures.ts +94 -0
  165. package/src/pdbeEntryMolecules.ts +37 -0
  166. package/src/siftsNumbering.ts +61 -0
  167. package/src/stripStopCodon.ts +20 -0
  168. package/src/structureFormat.ts +70 -0
  169. package/src/structureSources.ts +101 -0
  170. package/src/structureUrls.ts +171 -0
  171. package/src/types.ts +30 -0
  172. package/src/uniProtSearch.ts +212 -0
@@ -0,0 +1,342 @@
1
+ /** A polymer entity of a loaded structure: its mmCIF entity id, its one-letter
2
+ * sequence, and the molstar `label_seq_id` of each of those residues.
3
+ *
4
+ * The entity id is what lets every downstream step talk about "the gene's
5
+ * protein" by identity instead of by the fragile entity-[0] position.
6
+ *
7
+ * `seqIds` is what lets it talk about a *residue* by identity. Structure
8
+ * positions in this plugin are 0-based indices into `seq`; molstar addresses
9
+ * residues by `label_seq_id`. Those are related by `+1` only when the entity's
10
+ * sequence covers every residue from 1 — true for any mmCIF with an
11
+ * `entity_poly_seq` category (all of RCSB and AlphaFold) and for PDB files
12
+ * carrying SEQRES records, because molstar synthesizes the category from them.
13
+ *
14
+ * A PDB-format file with no SEQRES has no such category, so molstar falls back
15
+ * to `StructureSequence.fromHierarchy` and takes `label_seq_id` straight from
16
+ * the observed residues' author numbering: a chain whose coordinates start at
17
+ * residue 94 yields seqIds 94.., and an unobserved loop leaves a hole. Deriving
18
+ * the id arithmetically there is off by the whole offset, in both directions —
19
+ * every hover reads the wrong residue and every highlight paints the wrong one.
20
+ * So carry the real ids and convert through them.
21
+ */
22
+ export interface Entity {
23
+ entityId: string
24
+ seq: string
25
+ seqIds: number[]
26
+ /** author chain ids carrying this entity, e.g. ['A', 'B'] for a homodimer;
27
+ * what a user recognises from the PDB entry page, where the entity id is
28
+ * molstar's own bookkeeping */
29
+ chains: string[]
30
+ /** DNA, RNA or a hybrid, by molstar's entity subtype. Its one-letter
31
+ * sequence spells amino acids too (A, C, G, T, U), so it has to be kept
32
+ * out of the transcript alignment rather than merely scoring low. */
33
+ nucleicAcid?: boolean
34
+ /** `auth_seq_id` per position: the numbering the depositors chose, which
35
+ * for an RCSB entry is the one papers and UniProt cite (1TUP's position 154
36
+ * is R248) and what Mol*'s own hover label shows. Display only; every
37
+ * coordinate the plugin computes with stays a 0-based position, and molstar
38
+ * is addressed through `seqIds`. Absent when the model has no atomic
39
+ * hierarchy to read it from. See `residueNumber`. */
40
+ authSeqIds?: number[]
41
+ }
42
+
43
+ interface Column<T> {
44
+ rowCount: number
45
+ value(row: number): T
46
+ }
47
+
48
+ interface StructureModel {
49
+ obj?: {
50
+ data: {
51
+ entities?: {
52
+ subtype: Column<string>
53
+ getEntityIndex(id: string): number
54
+ }
55
+ sourceData?: { data: unknown }
56
+ sequence: {
57
+ sequences: readonly {
58
+ entityId: string
59
+ sequence: {
60
+ code: {
61
+ toArray(): ArrayLike<string>
62
+ }
63
+ seqId: {
64
+ toArray(): ArrayLike<number>
65
+ }
66
+ }
67
+ }[]
68
+ }
69
+ atomicHierarchy?: {
70
+ chains: {
71
+ label_entity_id: Column<string>
72
+ auth_asym_id: Column<string>
73
+ }
74
+ residues?: {
75
+ label_seq_id: Column<number>
76
+ auth_seq_id: Column<number>
77
+ }
78
+ residueAtomSegments?: { offsets: ArrayLike<number>; count: number }
79
+ chainAtomSegments?: { index: ArrayLike<number> }
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ /** Observed residues' label_seq_id -> auth_seq_id, per entity. The first chain
86
+ * carrying an entity wins, so a homodimer's copies agree. */
87
+ function authSeqIdsByEntity(model: StructureModel) {
88
+ const hierarchy = model.obj?.data.atomicHierarchy
89
+ const byEntity = new Map<string, Map<number, number>>()
90
+ const { residues, residueAtomSegments, chainAtomSegments, chains } =
91
+ hierarchy ?? {}
92
+ if (!residues || !residueAtomSegments || !chainAtomSegments || !chains) {
93
+ return undefined
94
+ }
95
+ for (let residue = 0; residue < residueAtomSegments.count; residue++) {
96
+ const atom = residueAtomSegments.offsets[residue]!
97
+ const entityId = chains.label_entity_id.value(
98
+ chainAtomSegments.index[atom]!,
99
+ )
100
+ let ids = byEntity.get(entityId)
101
+ if (!ids) {
102
+ ids = new Map()
103
+ byEntity.set(entityId, ids)
104
+ }
105
+ const labelSeqId = residues.label_seq_id.value(residue)
106
+ if (!ids.has(labelSeqId)) {
107
+ ids.set(labelSeqId, residues.auth_seq_id.value(residue))
108
+ }
109
+ }
110
+ return byEntity
111
+ }
112
+
113
+ /**
114
+ * Author numbering for every SEQRES position, observed or not. A residue with
115
+ * no atoms has no auth_seq_id of its own, so it takes the offset of the nearest
116
+ * observed residue before it (after it, at an unobserved N-terminus): a
117
+ * disordered loop keeps counting the way the paper does. With nothing observed
118
+ * the label numbering stands.
119
+ */
120
+ export function fillAuthSeqIds(
121
+ seqIds: number[],
122
+ observed: Map<number, number> | undefined,
123
+ ) {
124
+ const out: number[] = []
125
+ let offset: number | undefined
126
+ const firstObserved = seqIds.find(id => observed?.has(id))
127
+ if (firstObserved !== undefined) {
128
+ offset = observed!.get(firstObserved)! - firstObserved
129
+ }
130
+ for (const id of seqIds) {
131
+ const auth = observed?.get(id)
132
+ if (auth !== undefined) {
133
+ offset = auth - id
134
+ }
135
+ out.push(id + (offset ?? 0))
136
+ }
137
+ return out
138
+ }
139
+
140
+ /** The number a residue is called by: author numbering when the file carries
141
+ * it, else molstar's label_seq_id, which is the author numbering already for a
142
+ * SEQRES-less PDB and 1..N for everything else. */
143
+ export function residueNumber(entity: Entity | undefined, pos: number) {
144
+ return entity?.authSeqIds?.[pos] ?? entity?.seqIds[pos] ?? pos + 1
145
+ }
146
+
147
+ /**
148
+ * The 0-based half-open position range covering an inclusive range of author
149
+ * residue numbers, the form a paper or a spec names a site by ("R248",
150
+ * "residues 102-292"). Undefined when no residue of the entity carries a number
151
+ * in the range, so a typo selects nothing rather than something else.
152
+ */
153
+ export function residueRangeToPositions(
154
+ entity: Entity | undefined,
155
+ range: { start: number; end: number },
156
+ ) {
157
+ if (!entity) {
158
+ return undefined
159
+ }
160
+ let first: number | undefined
161
+ let last: number | undefined
162
+ for (let pos = 0; pos < entity.seq.length; pos++) {
163
+ const n = residueNumber(entity, pos)
164
+ if (n >= range.start && n <= range.end) {
165
+ first ??= pos
166
+ last = pos
167
+ }
168
+ }
169
+ return first === undefined || last === undefined
170
+ ? undefined
171
+ : { start: first, end: last + 1 }
172
+ }
173
+
174
+ function chainsByEntity(model: StructureModel) {
175
+ const chains = model.obj?.data.atomicHierarchy?.chains
176
+ const byEntity = new Map<string, string[]>()
177
+ if (chains) {
178
+ for (let i = 0; i < chains.label_entity_id.rowCount; i++) {
179
+ const entityId = chains.label_entity_id.value(i)
180
+ const chain = chains.auth_asym_id.value(i)
181
+ const list = byEntity.get(entityId) ?? []
182
+ if (!list.includes(chain)) {
183
+ list.push(chain)
184
+ }
185
+ byEntity.set(entityId, list)
186
+ }
187
+ }
188
+ return byEntity
189
+ }
190
+
191
+ // mmCIF entity_poly.type values for nucleic-acid polymers. Molstar derives the
192
+ // subtype from residue components when a file (PDB format) carries no
193
+ // entity_poly, so the flag is available for either format.
194
+ const NUCLEIC_ACID_SUBTYPES = new Set([
195
+ 'polydeoxyribonucleotide',
196
+ 'polyribonucleotide',
197
+ 'polydeoxyribonucleotide/polyribonucleotide hybrid',
198
+ 'peptide nucleic acid',
199
+ ])
200
+
201
+ function isNucleicAcid(model: StructureModel, entityId: string) {
202
+ const entities = model.obj?.data.entities
203
+ if (!entities) {
204
+ return false
205
+ }
206
+ const index = entities.getEntityIndex(entityId)
207
+ return index >= 0 && NUCLEIC_ACID_SUBTYPES.has(entities.subtype.value(index))
208
+ }
209
+
210
+ /** The slice of molstar's MmcifFormat data read here; other formats have no
211
+ * `db` */
212
+ interface MmcifSourceData {
213
+ db?: {
214
+ entity_poly?: {
215
+ entity_id: Column<string>
216
+ pdbx_seq_one_letter_code_can: Column<string>
217
+ }
218
+ }
219
+ }
220
+
221
+ function canonicalSequencesByEntity(model: StructureModel) {
222
+ const source = model.obj?.data.sourceData?.data as MmcifSourceData | undefined
223
+ const entityPoly = source?.db?.entity_poly
224
+ const byEntity = new Map<string, string>()
225
+ if (entityPoly) {
226
+ for (let i = 0; i < entityPoly.entity_id.rowCount; i++) {
227
+ byEntity.set(
228
+ entityPoly.entity_id.value(i),
229
+ entityPoly.pdbx_seq_one_letter_code_can.value(i).replaceAll(/\s/g, ''),
230
+ )
231
+ }
232
+ }
233
+ return byEntity
234
+ }
235
+
236
+ /**
237
+ * One letter per position, so `seq[pos]` and `seqIds[pos]` name the same
238
+ * residue. Molstar's `label` column is not that: it spells a residue with no
239
+ * one-letter code by its component id (MSE, TPO, ACE) and an alternate site as
240
+ * "(S|P)", which shifted every later position. `code` writes X for those; the
241
+ * mmCIF canonical sequence then supplies the parent residue (MSE is M) when it
242
+ * has one letter per position, which a chromophore spelled "TYG" does not.
243
+ */
244
+ export function oneLetterSequence(
245
+ codes: ArrayLike<string>,
246
+ canonical: string | undefined,
247
+ ) {
248
+ const letters = Array.from(codes)
249
+ return letters
250
+ .map((c, i) =>
251
+ c === 'X' && canonical?.length === letters.length ? canonical[i]! : c,
252
+ )
253
+ .join('')
254
+ }
255
+
256
+ export function extractEntities(model: StructureModel): Entity[] | undefined {
257
+ const chains = chainsByEntity(model)
258
+ const authIds = authSeqIdsByEntity(model)
259
+ const canonical = canonicalSequencesByEntity(model)
260
+ return model.obj?.data.sequence.sequences.map(s => {
261
+ const seqIds = Array.from(s.sequence.seqId.toArray())
262
+ return {
263
+ entityId: s.entityId,
264
+ seq: oneLetterSequence(
265
+ s.sequence.code.toArray(),
266
+ canonical.get(s.entityId),
267
+ ),
268
+ seqIds,
269
+ chains: chains.get(s.entityId) ?? [],
270
+ ...(isNucleicAcid(model, s.entityId) ? { nucleicAcid: true } : {}),
271
+ ...(authIds
272
+ ? { authSeqIds: fillAuthSeqIds(seqIds, authIds.get(s.entityId)) }
273
+ : {}),
274
+ }
275
+ })
276
+ }
277
+
278
+ /** A user-facing name for an entity: its chains when known, else its id. */
279
+ export function entityLabel(entity: Entity) {
280
+ const chains =
281
+ entity.chains.length > 0
282
+ ? `Chain ${entity.chains.join('/')}`
283
+ : `Entity ${entity.entityId}`
284
+ return `${chains} (${entity.seq.length} ${entity.nucleicAcid ? 'nt' : 'aa'})`
285
+ }
286
+
287
+ /** The protein chains' sequences, for the launch dialog's isoform matching,
288
+ * which has no use for a DNA strand that happens to align. */
289
+ export function extractStructureSequences(
290
+ model: StructureModel,
291
+ ): string[] | undefined {
292
+ return extractEntities(model)
293
+ ?.filter(e => !e.nucleicAcid)
294
+ .map(e => e.seq)
295
+ }
296
+
297
+ /**
298
+ * The molstar `label_seq_id`s for a set of 0-based structure positions. Unknown
299
+ * positions are dropped rather than guessed, so an out-of-range position paints
300
+ * nothing instead of painting something wrong.
301
+ */
302
+ export function toLabelSeqIds(
303
+ entity: Entity | undefined,
304
+ positions: Iterable<number>,
305
+ ): number[] {
306
+ if (!entity) {
307
+ return []
308
+ }
309
+ const out: number[] = []
310
+ for (const pos of positions) {
311
+ const id = entity.seqIds[pos]
312
+ if (id !== undefined) {
313
+ out.push(id)
314
+ }
315
+ }
316
+ return out
317
+ }
318
+
319
+ /** As toLabelSeqIds, for a half-open [start, end) structure-position range. */
320
+ export function rangeToLabelSeqIds(
321
+ entity: Entity | undefined,
322
+ range: { start: number; end: number } | undefined,
323
+ ): number[] {
324
+ if (!entity || !range) {
325
+ return []
326
+ }
327
+ const start = Math.max(0, range.start)
328
+ const end = Math.min(entity.seqIds.length, range.end)
329
+ return end > start ? entity.seqIds.slice(start, end) : []
330
+ }
331
+
332
+ /** Reverse of `seqIds`: molstar's label_seq_id -> 0-based structure position. */
333
+ export function makeLabelSeqIdIndex(entity: Entity | undefined) {
334
+ const index = new Map<number, number>()
335
+ entity?.seqIds.forEach((id, pos) => {
336
+ // first wins: duplicate ids (micro-heterogeneity) collapse to one residue
337
+ if (!index.has(id)) {
338
+ index.set(id, pos)
339
+ }
340
+ })
341
+ return index
342
+ }
@@ -0,0 +1,110 @@
1
+ function hostOf(url: string) {
2
+ try {
3
+ return new URL(url).host
4
+ } catch {
5
+ return url
6
+ }
7
+ }
8
+
9
+ const MAX_DETAIL = 200
10
+
11
+ function firstLine(body: string) {
12
+ const line = body.split('\n', 1)[0]?.trim() ?? ''
13
+ return line.length > MAX_DETAIL ? `${line.slice(0, MAX_DETAIL)}…` : line
14
+ }
15
+
16
+ /**
17
+ * The error a failed response deserves: status and host, plus at most the first
18
+ * line of the body. A whole body is an HTML error page or a megabyte of JSON,
19
+ * and pasting it into the dialog buries the one line that says what went wrong.
20
+ */
21
+ export async function httpError(response: Response, url: string) {
22
+ const detail = firstLine(await response.text().catch(() => ''))
23
+ const status = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''} from ${hostOf(url)}`
24
+ return new Error(detail ? `${status}: ${detail}` : status)
25
+ }
26
+
27
+ /**
28
+ * `fetch` rejects with a bare `TypeError: Failed to fetch` for a DNS failure,
29
+ * an offline browser and a refused cross-origin request alike, naming neither
30
+ * the host nor the cause. Anything else — an abort, most notably — passes
31
+ * through untouched.
32
+ */
33
+ export function networkError(url: string, cause: unknown) {
34
+ return cause instanceof TypeError
35
+ ? new Error(
36
+ `Could not reach ${hostOf(url)}. Check your network connection; the server may also be refusing cross-origin requests from this page.`,
37
+ { cause },
38
+ )
39
+ : cause
40
+ }
41
+
42
+ /**
43
+ * Request options plus the `fetch` to make the request with. Every network
44
+ * function here takes them, so a caller can hand in a host's instrumented
45
+ * fetch, a test double, or nothing at all and get `globalThis.fetch`. Resolved
46
+ * per call rather than captured at import, so a page that installs its fetch
47
+ * late is still honored.
48
+ */
49
+ export interface FetchOptions extends RequestInit {
50
+ fetch?: typeof globalThis.fetch
51
+ }
52
+
53
+ export async function rawfetch(url: string, args?: FetchOptions) {
54
+ const { fetch: f = globalThis.fetch, ...init } = args ?? {}
55
+ try {
56
+ return await f(url, init)
57
+ } catch (e) {
58
+ throw networkError(url, e)
59
+ }
60
+ }
61
+
62
+ export async function myfetch(url: string, args?: FetchOptions) {
63
+ const response = await rawfetch(url, args)
64
+
65
+ if (!response.ok) {
66
+ throw await httpError(response, url)
67
+ }
68
+
69
+ return response
70
+ }
71
+
72
+ export async function jsonfetch<T = unknown>(
73
+ url: string,
74
+ args?: FetchOptions,
75
+ ): Promise<T> {
76
+ // The caller names the shape it expects; `Response.json` promises `any`, and
77
+ // narrowing it here rather than at the return keeps the body free of casts.
78
+ const response: { json: () => Promise<T> } = await myfetch(url, args)
79
+ return response.json()
80
+ }
81
+
82
+ /**
83
+ * An AbortSignal's reason as a real Error. `signal.reason` is `any` — usually a
84
+ * DOMException, but a caller can abort with anything at all — and throwing a
85
+ * non-Error loses the stack and breaks `instanceof Error` checks in the UI's
86
+ * error rendering. Normalize at every throw site.
87
+ */
88
+ export function abortError(signal: AbortSignal) {
89
+ return signal.reason instanceof Error
90
+ ? signal.reason
91
+ : new Error('Aborted', { cause: signal.reason })
92
+ }
93
+
94
+ export function timeout(time: number, signal?: AbortSignal) {
95
+ return new Promise<void>((resolve, reject) => {
96
+ if (signal?.aborted) {
97
+ reject(abortError(signal))
98
+ } else {
99
+ const id = setTimeout(resolve, time)
100
+ signal?.addEventListener(
101
+ 'abort',
102
+ () => {
103
+ clearTimeout(id)
104
+ reject(abortError(signal))
105
+ },
106
+ { once: true },
107
+ )
108
+ }
109
+ })
110
+ }
package/src/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ // Alignment
2
+ export * from './types.ts'
3
+ export * from './mappings.ts'
4
+ export * from './pairwiseAlignment.ts'
5
+ export * from './alignmentQuality.ts'
6
+
7
+ // Coordinates
8
+ export * from './coordinates.ts'
9
+ export * from './invertMap.ts'
10
+ export * from './stripStopCodon.ts'
11
+
12
+ // Chain choice
13
+ export * from './chooseMappedEntity.ts'
14
+ export * from './extractStructureSequences.ts'
15
+ export * from './extractPerResidueConfidence.ts'
16
+ export * from './isoformRanking.ts'
17
+
18
+ // SIFTS
19
+ export * from './pdbUniProtMapping.ts'
20
+ export * from './siftsNumbering.ts'
21
+
22
+ // Structure sources
23
+ export * from './alphaFoldModels.ts'
24
+ export * from './pdbeBestStructures.ts'
25
+ export * from './pdbeEntryMolecules.ts'
26
+ export * from './structureSources.ts'
27
+ export * from './uniProtSearch.ts'
28
+ export * from './dbIds.ts'
29
+
30
+ // Urls and formats
31
+ export * from './structureUrls.ts'
32
+ export * from './structureFormat.ts'
33
+ export * from './caCoordsToPdb.ts'
34
+ export * from './fetchUtils.ts'
@@ -0,0 +1,4 @@
1
+ /** Swap keys and values of a numeric map, for reading a position map backwards. */
2
+ export function invertMap(arg: Record<number, number>): Record<number, number> {
3
+ return Object.fromEntries(Object.entries(arg).map(([a, b]) => [b, +a]))
4
+ }
@@ -0,0 +1,139 @@
1
+ import { stripStopCodon } from './stripStopCodon.ts'
2
+ import {
3
+ alignTranscriptToEntity,
4
+ chooseMappedEntity,
5
+ } from './chooseMappedEntity.ts'
6
+
7
+ /** A transcript isoform, named by whatever id the caller ranks by. `seq` is its
8
+ * translation, absent when none could be computed. */
9
+ export interface Isoform {
10
+ id: string
11
+ seq?: string
12
+ }
13
+
14
+ export interface RankedIsoform {
15
+ id: string
16
+ length: number
17
+ /** identical residues against the structure, for an isoform that does not
18
+ * match it exactly; undefined with no structure to compare to */
19
+ identical?: number
20
+ /** that alignment's score, which is what ranks non-matching isoforms */
21
+ score?: number
22
+ }
23
+
24
+ export interface ClassifiedIsoforms {
25
+ // protein matches the structure residues, longest first
26
+ matches: RankedIsoform[]
27
+ // has a protein sequence but doesn't match the structure, best-aligning first
28
+ nonMatches: RankedIsoform[]
29
+ // ids of the isoforms no protein sequence could be computed for
30
+ noData: string[]
31
+ }
32
+
33
+ // The picker re-ranks on every render and each non-exact isoform costs an
34
+ // O(len²) alignment, so results are cached by sequence pair: a titin-sized
35
+ // isoform against a long chain is a second of main-thread work each time.
36
+ const alignmentCache = new Map<string, { identical: number; score: number }>()
37
+ const ALIGNMENT_CACHE_LIMIT = 200
38
+
39
+ function alignToStructure(isoform: string, structure: string) {
40
+ const key = `${isoform}\n${structure}`
41
+ let result = alignmentCache.get(key)
42
+ if (result === undefined) {
43
+ const scored = alignTranscriptToEntity(isoform, structure, 'smith_waterman')
44
+ result = { identical: scored?.matches ?? 0, score: scored?.score ?? 0 }
45
+ if (alignmentCache.size >= ALIGNMENT_CACHE_LIMIT) {
46
+ alignmentCache.clear()
47
+ }
48
+ alignmentCache.set(key, result)
49
+ }
50
+ return result
51
+ }
52
+
53
+ /**
54
+ * The single rule for ranking transcript isoforms against a structure, shared
55
+ * by a picker UI and an auto-selection. An isoform whose translation is the
56
+ * structure's sequence wins outright. Among the rest, the best alignment score
57
+ * comes first, then the most identical residues, then length.
58
+ *
59
+ * The score has to lead: an isoform carrying an exon the structure lacks
60
+ * aligns every structure residue too, across a gap, so it ties on identical
61
+ * residues with the isoform the structure was made from (1MH1 against Rac1
62
+ * and Rac1b).
63
+ */
64
+ export function classifyIsoforms({
65
+ isoforms,
66
+ structureSequence,
67
+ }: {
68
+ isoforms: readonly Isoform[]
69
+ structureSequence?: string
70
+ }): ClassifiedIsoforms {
71
+ const matches: RankedIsoform[] = []
72
+ const nonMatches: RankedIsoform[] = []
73
+ const noData: string[] = []
74
+ const structure = structureSequence
75
+ ? stripStopCodon(structureSequence)
76
+ : undefined
77
+ for (const { id, seq } of isoforms) {
78
+ if (seq === undefined) {
79
+ noData.push(id)
80
+ } else if (structure && stripStopCodon(seq) === structure) {
81
+ matches.push({ id, length: seq.length })
82
+ } else {
83
+ nonMatches.push({
84
+ id,
85
+ length: seq.length,
86
+ ...(structure ? alignToStructure(seq, structure) : {}),
87
+ })
88
+ }
89
+ }
90
+ const byLengthDesc = (a: RankedIsoform, b: RankedIsoform) =>
91
+ b.length - a.length
92
+ const byAlignmentThenLength = (a: RankedIsoform, b: RankedIsoform) =>
93
+ (b.score ?? 0) - (a.score ?? 0) ||
94
+ (b.identical ?? 0) - (a.identical ?? 0) ||
95
+ byLengthDesc(a, b)
96
+ return {
97
+ matches: matches.toSorted(byLengthDesc),
98
+ nonMatches: nonMatches.toSorted(byAlignmentThenLength),
99
+ noData,
100
+ }
101
+ }
102
+
103
+ /** The id of the isoform to open, by the ranking above. */
104
+ export function selectBestTranscript(args: {
105
+ isoforms: readonly Isoform[]
106
+ structureSequence?: string
107
+ }) {
108
+ const { matches, nonMatches } = classifyIsoforms(args)
109
+ return (matches[0] ?? nonMatches[0])?.id
110
+ }
111
+
112
+ /**
113
+ * Which of a structure's protein chains isoforms are compared against: one some
114
+ * isoform translates to exactly, else the chain `chooseMappedEntity` would map
115
+ * the longest isoform to, never simply the first (CDK2, on 1H26).
116
+ * `structureSequences` must hold protein chains only.
117
+ */
118
+ export function pickStructureSequence(
119
+ structureSequences: string[] | undefined,
120
+ isoforms: readonly Isoform[] | undefined,
121
+ ): string | undefined {
122
+ const translations = (isoforms ?? []).flatMap(({ seq }) =>
123
+ seq === undefined ? [] : [stripStopCodon(seq)],
124
+ )
125
+ const translated = new Set(translations)
126
+ const exact = structureSequences?.find(s => translated.has(stripStopCodon(s)))
127
+ const longest = translations.reduce<string | undefined>(
128
+ (a, b) => (a !== undefined && a.length >= b.length ? a : b),
129
+ undefined,
130
+ )
131
+ const aligned =
132
+ structureSequences && longest
133
+ ? chooseMappedEntity(longest, structureSequences, 'smith_waterman')
134
+ : undefined
135
+ return (
136
+ exact ??
137
+ (aligned ? structureSequences?.[aligned.index] : structureSequences?.[0])
138
+ )
139
+ }