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,320 @@
1
+ // SIFTS UniProt <-> PDB residue mapping.
2
+ //
3
+ // UniProt feature coordinates are positions in the UniProt canonical sequence.
4
+ // For an AlphaFold model that *is* the structure's own sequence, so a feature at
5
+ // UniProt position p sits at structure position p - 1. For an experimental PDB
6
+ // entry it is not: the deposited construct is usually a fragment, often with
7
+ // tags or engineered residues, so the two numberings differ by a per-segment
8
+ // offset (1TUP's p53 chain starts at UniProt 94; 6VXX's spike SEQRES 33 is
9
+ // UniProt 14). SIFTS is the authoritative alignment between the two, published
10
+ // per entity/chain by PDBe.
11
+ //
12
+ // `residue_number` in the PDBe response is the 1-based index into the entity's
13
+ // SEQRES, i.e. label_seq_id, which is exactly this plugin's structure-sequence
14
+ // position + 1 (see applyLociInteractivity.ts for the other place that boundary
15
+ // is crossed).
16
+
17
+ import { jsonfetch, timeout } from './fetchUtils.ts'
18
+
19
+ import type { FetchOptions } from './fetchUtils.ts'
20
+
21
+ /** One contiguous UniProt <-> structure correspondence for a single entity.
22
+ * `unp*` are 1-based UniProt positions; `struct*` are 0-based inclusive
23
+ * structure-sequence positions, this plugin's native coordinate. */
24
+ export interface UniProtStructureSegment {
25
+ entityId: string
26
+ /** author chain id, when the response names it */
27
+ chainId?: string
28
+ unpStart: number
29
+ unpEnd: number
30
+ structStart: number
31
+ structEnd: number
32
+ /** the endpoints' author residue numbers, the numbering a paper cites and a
33
+ * Mol* hover shows; absent when the response omits them */
34
+ authorStart?: number
35
+ authorEnd?: number
36
+ }
37
+
38
+ export interface UniProtStructureMapping {
39
+ accession: string
40
+ /** UniProt entry name, e.g. "P53_HUMAN" */
41
+ name?: string
42
+ segments: UniProtStructureSegment[]
43
+ }
44
+
45
+ /**
46
+ * Maps a 1-based UniProt position to a 0-based structure-sequence position.
47
+ * Returns undefined for positions outside the modeled region, so callers drop
48
+ * the feature instead of drawing it somewhere misleading.
49
+ */
50
+ export type MapUniProtPosition = (uniprotPos: number) => number | undefined
51
+
52
+ export function pdbeSiftsUrl(pdbId: string) {
53
+ return `https://www.ebi.ac.uk/pdbe/api/mappings/uniprot/${pdbId.toLowerCase()}`
54
+ }
55
+
56
+ const SIFTS_RETRY_DELAYS_MS = [1000, 3000]
57
+ // bounded, since a view reports itself loading until SIFTS answers
58
+ const SIFTS_TIMEOUT_MS = 20_000
59
+
60
+ /** SIFTS for an entry, retried twice: one failed request would otherwise
61
+ * leave a fusion construct mapped onto its partner for the whole session. */
62
+ export async function fetchUniProtStructureMappings(
63
+ pdbId: string,
64
+ opts?: FetchOptions,
65
+ ) {
66
+ const { signal: caller, ...rest } = opts ?? {}
67
+ const signal = caller ?? undefined
68
+ const deadline = () =>
69
+ signal
70
+ ? AbortSignal.any([signal, AbortSignal.timeout(SIFTS_TIMEOUT_MS)])
71
+ : AbortSignal.timeout(SIFTS_TIMEOUT_MS)
72
+ for (let attempt = 0; ; attempt++) {
73
+ try {
74
+ return parseUniProtStructureMappings(
75
+ await jsonfetch(pdbeSiftsUrl(pdbId), { ...rest, signal: deadline() }),
76
+ )
77
+ } catch (e) {
78
+ const delay = SIFTS_RETRY_DELAYS_MS[attempt]
79
+ if (delay === undefined) {
80
+ throw e
81
+ }
82
+ await timeout(delay, signal)
83
+ }
84
+ }
85
+ }
86
+
87
+ function isRecord(value: unknown): value is Record<string, unknown> {
88
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
89
+ }
90
+
91
+ function finiteNumber(value: unknown) {
92
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
93
+ }
94
+
95
+ function residueNumber(endpoint: unknown) {
96
+ return isRecord(endpoint) ? finiteNumber(endpoint.residue_number) : undefined
97
+ }
98
+
99
+ function authorResidueNumber(endpoint: unknown) {
100
+ return isRecord(endpoint)
101
+ ? finiteNumber(endpoint.author_residue_number)
102
+ : undefined
103
+ }
104
+
105
+ function parseSegment(mapping: unknown): UniProtStructureSegment | undefined {
106
+ if (!isRecord(mapping)) {
107
+ return undefined
108
+ }
109
+ const entityId = finiteNumber(mapping.entity_id)
110
+ const unpStart = finiteNumber(mapping.unp_start)
111
+ const unpEnd = finiteNumber(mapping.unp_end)
112
+ const structStart = residueNumber(mapping.start)
113
+ const structEnd = residueNumber(mapping.end)
114
+ const authorStart = authorResidueNumber(mapping.start)
115
+ const authorEnd = authorResidueNumber(mapping.end)
116
+ return entityId === undefined ||
117
+ unpStart === undefined ||
118
+ unpEnd === undefined ||
119
+ structStart === undefined ||
120
+ structEnd === undefined
121
+ ? undefined
122
+ : {
123
+ entityId: String(entityId),
124
+ ...(typeof mapping.chain_id === 'string'
125
+ ? { chainId: mapping.chain_id }
126
+ : {}),
127
+ unpStart,
128
+ unpEnd,
129
+ // SEQRES/label_seq_id is 1-based, structure positions are 0-based
130
+ structStart: structStart - 1,
131
+ structEnd: structEnd - 1,
132
+ ...(authorStart === undefined ? {} : { authorStart }),
133
+ ...(authorEnd === undefined ? {} : { authorEnd }),
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Parses a PDBe `mappings/uniprot/{pdbId}` response into one entry per UniProt
139
+ * accession. Unparseable segments are skipped rather than failing the whole
140
+ * response — a chimera with one malformed segment still maps its other chains.
141
+ * The response is keyed by pdb id, so the single entry is taken whatever its key.
142
+ */
143
+ export function parseUniProtStructureMappings(
144
+ json: unknown,
145
+ ): UniProtStructureMapping[] {
146
+ const entry = isRecord(json) ? Object.values(json)[0] : undefined
147
+ const uniprot = isRecord(entry) ? entry.UniProt : undefined
148
+ if (!isRecord(uniprot)) {
149
+ return []
150
+ }
151
+ const result: UniProtStructureMapping[] = []
152
+ for (const [accession, value] of Object.entries(uniprot)) {
153
+ const mappings = isRecord(value) ? value.mappings : undefined
154
+ const segments = Array.isArray(mappings)
155
+ ? mappings.map(parseSegment).filter(s => s !== undefined)
156
+ : []
157
+ if (segments.length > 0) {
158
+ result.push({
159
+ accession,
160
+ name:
161
+ isRecord(value) && typeof value.name === 'string'
162
+ ? value.name
163
+ : undefined,
164
+ segments,
165
+ })
166
+ }
167
+ }
168
+ return result
169
+ }
170
+
171
+ function coveredResidues(segments: UniProtStructureSegment[]) {
172
+ return segments.reduce((a, s) => a + (s.structEnd - s.structStart + 1), 0)
173
+ }
174
+
175
+ /** The entity a SIFTS segment is matched against: its mmCIF id and the author
176
+ * chains carrying it. */
177
+ export interface SegmentEntity {
178
+ entityId: string
179
+ chains: readonly string[]
180
+ }
181
+
182
+ /**
183
+ * The segments of one accession that describe this entity. SIFTS numbers
184
+ * entities as the mmCIF does, and a PDB-format file loaded through Mol* numbers
185
+ * them per chain (1H26.pdb's chain C is entity 2, which in SIFTS is cyclin A),
186
+ * so a segment naming its chain is matched by chain.
187
+ */
188
+ function segmentsForEntity(
189
+ mapping: UniProtStructureMapping,
190
+ entity: SegmentEntity,
191
+ ) {
192
+ return dedupeSegments(
193
+ mapping.segments.filter(s =>
194
+ s.chainId === undefined
195
+ ? s.entityId === entity.entityId
196
+ : entity.chains.includes(s.chainId),
197
+ ),
198
+ )
199
+ }
200
+
201
+ /**
202
+ * Picks the UniProt entry that describes a given entity, with its segments
203
+ * narrowed to that entity. Only the entity the plugin has mapped to the
204
+ * transcript is relevant — a heteromer maps each of its chains to a different
205
+ * accession, and using the wrong one would silently annotate the wrong protein.
206
+ * When several accessions cover the same entity (a chimeric construct) the one
207
+ * contributing the most residues wins.
208
+ */
209
+ export function chooseUniProtMappingForEntity(
210
+ mappings: UniProtStructureMapping[],
211
+ entity: SegmentEntity | undefined,
212
+ ): UniProtStructureMapping | undefined {
213
+ if (entity === undefined) {
214
+ return undefined
215
+ }
216
+ let best: UniProtStructureMapping | undefined
217
+ for (const mapping of mappings) {
218
+ const segments = segmentsForEntity(mapping, entity)
219
+ if (
220
+ segments.length > 0 &&
221
+ (!best || coveredResidues(segments) > coveredResidues(best.segments))
222
+ ) {
223
+ best = { ...mapping, segments }
224
+ }
225
+ }
226
+ return best
227
+ }
228
+
229
+ const OWN_PSEUDOCOUNT = 5
230
+ const MIN_OWN_IDENTITY = 0.5
231
+
232
+ /**
233
+ * Mapped structure positions that SIFTS assigns to a protein other than the
234
+ * transcript's, on a chain that fuses two. Local alignment bridges a fusion
235
+ * boundary, and on every GPCR fusion construct checked it scattered receptor
236
+ * residues onto the partner, 33 of them onto T4 lysozyme in 2RH1.
237
+ *
238
+ * `mapped` is each mapped structure position and whether its pair is
239
+ * identical. The transcript's own accession is the one whose covered positions
240
+ * are most often identical, plus a pseudocount, as the chain picker scores
241
+ * chains. A count of covered positions would not do: TP53's 11-residue peptide
242
+ * fused to CDK2 covers 11 positions against 224 chance hits on the kinase, and
243
+ * counting unmapped the peptide. Unless that accession reaches half identity
244
+ * the transcript is taken to be neither protein, and nothing is unmapped.
245
+ * Residues SIFTS assigns to no accession, such as tags, stay mapped.
246
+ */
247
+ export function fusionPartnerPositions(
248
+ mappings: UniProtStructureMapping[],
249
+ entity: SegmentEntity | undefined,
250
+ mapped: ReadonlyMap<number, boolean>,
251
+ ) {
252
+ const partners = new Set<number>()
253
+ const byAccession = entity
254
+ ? mappings
255
+ .map(m => segmentsForEntity(m, entity))
256
+ .filter(segments => segments.length > 0)
257
+ : []
258
+ if (byAccession.length < 2) {
259
+ return partners
260
+ }
261
+ const covers = (segments: UniProtStructureSegment[], pos: number) =>
262
+ segments.some(s => pos >= s.structStart && pos <= s.structEnd)
263
+ const identity = byAccession.map(segments => {
264
+ let covered = 0
265
+ let identical = 0
266
+ for (const [pos, same] of mapped) {
267
+ if (covers(segments, pos)) {
268
+ covered++
269
+ identical += same ? 1 : 0
270
+ }
271
+ }
272
+ return identical / (covered + OWN_PSEUDOCOUNT)
273
+ })
274
+ const ownIdentity = Math.max(...identity)
275
+ if (ownIdentity < MIN_OWN_IDENTITY) {
276
+ return partners
277
+ }
278
+ const own = byAccession[identity.indexOf(ownIdentity)]!
279
+ for (const pos of mapped.keys()) {
280
+ if (
281
+ !covers(own, pos) &&
282
+ byAccession.some(segments => segments !== own && covers(segments, pos))
283
+ ) {
284
+ partners.add(pos)
285
+ }
286
+ }
287
+ return partners
288
+ }
289
+
290
+ // SIFTS lists one mapping per *chain*, so the several chains of a homodimer
291
+ // repeat the same entity-level correspondence. They collapse to one segment.
292
+ function dedupeSegments(segments: UniProtStructureSegment[]) {
293
+ const seen = new Set<string>()
294
+ return segments.filter(s => {
295
+ const key = `${s.unpStart}-${s.unpEnd}-${s.structStart}-${s.structEnd}`
296
+ const isNew = !seen.has(key)
297
+ seen.add(key)
298
+ return isNew
299
+ })
300
+ }
301
+
302
+ /** 1-based UniProt position -> 0-based structure position, for an AlphaFold
303
+ * model whose sequence is the UniProt sequence. */
304
+ export const identityUniProtPositionMap: MapUniProtPosition = uniprotPos =>
305
+ uniprotPos - 1
306
+
307
+ export function makeUniProtPositionMap(
308
+ segments: UniProtStructureSegment[],
309
+ ): MapUniProtPosition {
310
+ return uniprotPos => {
311
+ const segment = segments.find(
312
+ s => uniprotPos >= s.unpStart && uniprotPos <= s.unpEnd,
313
+ )
314
+ if (!segment) {
315
+ return undefined
316
+ }
317
+ const pos = segment.structStart + (uniprotPos - segment.unpStart)
318
+ return pos <= segment.structEnd ? pos : undefined
319
+ }
320
+ }
@@ -0,0 +1,94 @@
1
+ // PDBe's SIFTS-derived list of the experimental structures covering a UniProt
2
+ // entry, one row per chain, ranked by PDBe (coverage, then resolution). The
3
+ // dialog shows one row per entry, so the chains of a homomer collapse into it.
4
+
5
+ export interface PdbStructureEntry {
6
+ pdbId: string
7
+ experimentalMethod: string
8
+ /** Å; absent for NMR and some EM entries */
9
+ resolution?: number
10
+ /** 1-based inclusive UniProt positions the entry covers */
11
+ unpStart: number
12
+ unpEnd: number
13
+ /** share of the UniProt sequence the entry covers, 0..1 */
14
+ coverage: number
15
+ chains: string[]
16
+ }
17
+
18
+ export function pdbeBestStructuresUrl(uniprotId: string) {
19
+ return `https://www.ebi.ac.uk/pdbe/api/mappings/best_structures/${uniprotId}`
20
+ }
21
+
22
+ // A PDB id is four characters, the first a digit: 1TUP, 6ZIO. Checked before
23
+ // a typed one reaches a fetch, so three characters on the way to four are not
24
+ // a 404 apiece.
25
+ export function isPdbId(value: string) {
26
+ return /^[1-9][A-Za-z0-9]{3}$/.test(value)
27
+ }
28
+
29
+ export function rcsbEntryUrl(pdbId: string) {
30
+ return `https://www.rcsb.org/structure/${pdbId.toUpperCase()}`
31
+ }
32
+
33
+ function isRecord(value: unknown): value is Record<string, unknown> {
34
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
35
+ }
36
+
37
+ function finiteNumber(value: unknown) {
38
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
39
+ }
40
+
41
+ /**
42
+ * Groups the per-chain rows into one entry per PDB id, keeping PDBe's order.
43
+ * An entry whose chains cover different UniProt ranges (a construct
44
+ * crystallised beside a different fragment of itself) keeps its first row's
45
+ * range, which is the one PDBe ranked it by. The response is keyed by
46
+ * accession, so the single entry is taken whatever its key.
47
+ */
48
+ export function parseBestStructures(json: unknown): PdbStructureEntry[] {
49
+ const rows = isRecord(json) ? Object.values(json)[0] : undefined
50
+ if (!Array.isArray(rows)) {
51
+ return []
52
+ }
53
+ const byId = new Map<string, PdbStructureEntry>()
54
+ for (const row of rows) {
55
+ if (!isRecord(row)) {
56
+ continue
57
+ }
58
+ const pdbId = typeof row.pdb_id === 'string' ? row.pdb_id : undefined
59
+ const chain = typeof row.chain_id === 'string' ? row.chain_id : undefined
60
+ const unpStart = finiteNumber(row.unp_start)
61
+ const unpEnd = finiteNumber(row.unp_end)
62
+ const coverage = finiteNumber(row.coverage)
63
+ if (
64
+ pdbId === undefined ||
65
+ chain === undefined ||
66
+ unpStart === undefined ||
67
+ unpEnd === undefined ||
68
+ coverage === undefined
69
+ ) {
70
+ continue
71
+ }
72
+ const existing = byId.get(pdbId)
73
+ if (existing) {
74
+ if (!existing.chains.includes(chain)) {
75
+ existing.chains.push(chain)
76
+ }
77
+ continue
78
+ }
79
+ const resolution = finiteNumber(row.resolution)
80
+ byId.set(pdbId, {
81
+ pdbId,
82
+ experimentalMethod:
83
+ typeof row.experimental_method === 'string'
84
+ ? row.experimental_method
85
+ : 'unknown',
86
+ ...(resolution === undefined ? {} : { resolution }),
87
+ unpStart,
88
+ unpEnd,
89
+ coverage,
90
+ chains: [chain],
91
+ })
92
+ }
93
+ return [...byId.values()]
94
+ }
@@ -0,0 +1,37 @@
1
+ // The polymer entities of a PDB entry, as PDBe already knows them. The dialog
2
+ // wants the entry's residues only to annotate the isoform list, and reading
3
+ // them out of the structure file meant downloading and parsing the whole
4
+ // mmCIF — minutes for a ribosome, for a label. This is a few kB of JSON, and
5
+ // the view downloads the file itself when the user actually launches.
6
+
7
+ export function pdbeEntryMoleculesUrl(pdbId: string) {
8
+ return `https://www.ebi.ac.uk/pdbe/api/pdb/entry/molecules/${pdbId.toLowerCase()}`
9
+ }
10
+
11
+ function isRecord(value: unknown): value is Record<string, unknown> {
12
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
13
+ }
14
+
15
+ /**
16
+ * One sequence per protein entity, in entity order. Nucleic-acid entities are
17
+ * left out: A, C, G, T and U are amino-acid letters too, so a DNA chain scores
18
+ * as a candidate rather than losing honestly.
19
+ */
20
+ export function parseEntryMolecules(json: unknown): string[] {
21
+ const entities = isRecord(json) ? Object.values(json)[0] : undefined
22
+ if (!Array.isArray(entities)) {
23
+ return []
24
+ }
25
+ return entities.flatMap(entity => {
26
+ if (!isRecord(entity)) {
27
+ return []
28
+ }
29
+ const { molecule_type: type, sequence } = entity
30
+ return typeof type === 'string' &&
31
+ type.startsWith('polypeptide') &&
32
+ typeof sequence === 'string' &&
33
+ sequence.length > 0
34
+ ? [sequence]
35
+ : []
36
+ })
37
+ }
@@ -0,0 +1,61 @@
1
+ // How a PDB entry numbers the residues of a UniProt sequence. Author numbering
2
+ // is what a paper cites and what a viewer lights a named residue by, and it is
3
+ // not the UniProt numbering for every entry: haemoglobin's chains number from
4
+ // the mature protein, so author residue 1 is UniProt residue 2 (2HHB, measured
5
+ // 2026-09-12). SIFTS records the correspondence per chain segment, and
6
+ // `fetchUniProtStructureMappings` is what asks PDBe for it.
7
+
8
+ import type { UniProtStructureSegment } from './pdbUniProtMapping.ts'
9
+
10
+ export interface AuthorRange {
11
+ start: number
12
+ end: number
13
+ chain: string
14
+ /** author number minus UniProt number in the chosen segment */
15
+ shift: number
16
+ }
17
+
18
+ /** The segments SIFTS gives one accession, chain ids and author numbers
19
+ * included, out of a whole entry's mappings. */
20
+ export function segmentsForAccession(
21
+ mappings: readonly {
22
+ accession: string
23
+ segments: UniProtStructureSegment[]
24
+ }[],
25
+ accession: string,
26
+ ) {
27
+ return mappings.find(m => m.accession === accession)?.segments ?? []
28
+ }
29
+
30
+ /**
31
+ * The author-numbered range a UniProt range is cited by in the entry: the
32
+ * segment overlapping most of the range decides the shift. Undefined when no
33
+ * chain of the entry covers any of it, or when SIFTS named no author numbers.
34
+ */
35
+ export function toAuthorRange(
36
+ segments: readonly UniProtStructureSegment[],
37
+ range: { start: number; end: number },
38
+ ): AuthorRange | undefined {
39
+ const overlap = (s: UniProtStructureSegment) =>
40
+ Math.min(s.unpEnd, range.end) - Math.max(s.unpStart, range.start) + 1
41
+ const best = segments
42
+ .flatMap(s => {
43
+ const { chainId, authorStart, unpStart, unpEnd } = s
44
+ return chainId !== undefined &&
45
+ authorStart !== undefined &&
46
+ overlap(s) > 0
47
+ ? [{ chainId, authorStart, unpStart, unpEnd, overlap: overlap(s) }]
48
+ : []
49
+ })
50
+ .toSorted((a, b) => b.overlap - a.overlap)[0]
51
+ if (!best) {
52
+ return undefined
53
+ }
54
+ const shift = best.authorStart - best.unpStart
55
+ return {
56
+ start: Math.max(range.start, best.unpStart) + shift,
57
+ end: Math.min(range.end, best.unpEnd) + shift,
58
+ chain: best.chainId,
59
+ shift,
60
+ }
61
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Drop the terminal stop codon(s) from a translated protein sequence.
3
+ *
4
+ * Only trailing `*` go: an interior stop (mis-annotated CDS, selenoprotein
5
+ * read-through) occupies a real codon position, and deleting it would shift
6
+ * every later residue out of step with g2p's codon-indexed transcript
7
+ * coordinates — offsetting every genome↔structure hover past that point.
8
+ */
9
+ export function stripStopCodon(seq: string) {
10
+ return seq.replace(/\*+$/, '')
11
+ }
12
+
13
+ /**
14
+ * Strip every stop codon, including interior ones. Only for sequences handed to
15
+ * an external similarity search (Foldseek, AlphaFold), where `*` is not a valid
16
+ * query character and no coordinate depends on the result's positions.
17
+ */
18
+ export function stripAllStopCodons(seq: string) {
19
+ return seq.replaceAll('*', '')
20
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Which molstar trajectory parser a structure needs.
3
+ *
4
+ * Getting this wrong is not a graceful failure in either direction: parsing a
5
+ * PDB file as mmCIF throws ("Unexpected token. Expected data_, loop_, or data
6
+ * name"), and — worse — parsing an mmCIF as PDB *succeeds* and yields a model
7
+ * with zero polymer entities and thousands of misread atoms, so the view loads
8
+ * with no sequence, no alignment and no genome mapping, and nothing reports an
9
+ * error.
10
+ *
11
+ * So detection lives here and is applied as the default at every entry point,
12
+ * rather than at one of them.
13
+ *
14
+ * The two names are the subset of molstar's `BuiltInTrajectoryFormat` a
15
+ * structure loaded from a file or a url can be, spelled out rather than
16
+ * imported so nothing here depends on molstar.
17
+ */
18
+ export type StructureFormat = 'pdb' | 'mmcif'
19
+
20
+ // Only the PDB set is enumerated: mmCIF is the fallback (see below), so listing
21
+ // its extensions would be a second source of truth that could disagree.
22
+ const PDB_EXTENSIONS = new Set(['pdb', 'ent', 'pdb1', 'pdb2', 'pdb3'])
23
+
24
+ /** Trailing extension of a filename or URL, lowercased, ignoring any query or
25
+ * fragment and any `.gz` wrapper (`…/pdb1tup.ent.gz` -> `ent`). */
26
+ export function structureFileExtension(nameOrUrl: string) {
27
+ const path = nameOrUrl.split(/[?#]/)[0] ?? ''
28
+ const name = path.split('/').pop() ?? ''
29
+ const parts = name.toLowerCase().split('.')
30
+ if (parts.at(-1) === 'gz') {
31
+ parts.pop()
32
+ }
33
+ return parts.length > 1 ? (parts.at(-1) ?? '') : ''
34
+ }
35
+
36
+ /**
37
+ * Format for a filename or URL. Unknown extensions fall back to mmCIF, which is
38
+ * what every URL this plugin generates itself serves (AlphaFold and RCSB both
39
+ * hand out `.cif`).
40
+ */
41
+ export function structureFormatFromName(nameOrUrl: string): StructureFormat {
42
+ return PDB_EXTENSIONS.has(structureFileExtension(nameOrUrl)) ? 'pdb' : 'mmcif'
43
+ }
44
+
45
+ /** Whether a URL points at a binary-encoded structure, which molstar must be
46
+ * told to download as bytes rather than text. */
47
+ export function isBinaryStructureUrl(url: string) {
48
+ return structureFileExtension(url) === 'bcif'
49
+ }
50
+
51
+ /**
52
+ * Format for structure text that arrived without a filename — an inline `data`
53
+ * snapshot, a pasted file, or the PDB the plugin synthesizes from Foldseek Cα
54
+ * coordinates. Sniffs the content, which is strictly more reliable than a name
55
+ * and is the only signal available here.
56
+ *
57
+ * mmCIF is line-oriented and its first meaningful line is a `data_` block
58
+ * header (comments and blank lines may precede it); PDB has none.
59
+ */
60
+ export function structureFormatFromContent(data: string): StructureFormat {
61
+ // a data_ header can only appear in the first few lines; don't scan a 30MB
62
+ // structure to find out
63
+ for (const line of data.slice(0, 4096).split('\n')) {
64
+ const trimmed = line.trim()
65
+ if (trimmed !== '' && !trimmed.startsWith('#')) {
66
+ return trimmed.toLowerCase().startsWith('data_') ? 'mmcif' : 'pdb'
67
+ }
68
+ }
69
+ return 'pdb'
70
+ }