@apollo-annotation/jbrowse-plugin-apollo 0.1.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 (116) hide show
  1. package/README.md +76 -0
  2. package/dist/index.esm.js +10248 -0
  3. package/dist/index.esm.js.map +1 -0
  4. package/dist/index.js +7 -0
  5. package/dist/jbrowse-plugin-apollo.cjs.development.js +10298 -0
  6. package/dist/jbrowse-plugin-apollo.cjs.development.js.map +1 -0
  7. package/dist/jbrowse-plugin-apollo.cjs.production.min.js +2 -0
  8. package/dist/jbrowse-plugin-apollo.cjs.production.min.js.map +1 -0
  9. package/dist/jbrowse-plugin-apollo.umd.development.js +46957 -0
  10. package/dist/jbrowse-plugin-apollo.umd.development.js.map +1 -0
  11. package/dist/jbrowse-plugin-apollo.umd.production.min.js +2 -0
  12. package/dist/jbrowse-plugin-apollo.umd.production.min.js.map +1 -0
  13. package/package.json +130 -0
  14. package/src/ApolloInternetAccount/addMenuItems.ts +94 -0
  15. package/src/ApolloInternetAccount/components/AuthTypeSelector.tsx +121 -0
  16. package/src/ApolloInternetAccount/components/LoginButtons.tsx +62 -0
  17. package/src/ApolloInternetAccount/components/LoginIcons.tsx +74 -0
  18. package/src/ApolloInternetAccount/configSchema.ts +26 -0
  19. package/src/ApolloInternetAccount/index.ts +2 -0
  20. package/src/ApolloInternetAccount/model.ts +448 -0
  21. package/src/ApolloJobModel.ts +117 -0
  22. package/src/ApolloSequenceAdapter/ApolloSequenceAdapter.ts +186 -0
  23. package/src/ApolloSequenceAdapter/configSchema.ts +12 -0
  24. package/src/ApolloSequenceAdapter/index.ts +21 -0
  25. package/src/ApolloSixFrameRenderer/ApolloSixFrameRenderer.tsx +12 -0
  26. package/src/ApolloSixFrameRenderer/components/ApolloRendering.tsx +692 -0
  27. package/src/ApolloSixFrameRenderer/configSchema.ts +7 -0
  28. package/src/ApolloSixFrameRenderer/index.ts +3 -0
  29. package/src/ApolloTextSearchAdapter/ApolloTextSearchAdapter.ts +64 -0
  30. package/src/ApolloTextSearchAdapter/configSchema.ts +24 -0
  31. package/src/ApolloTextSearchAdapter/index.ts +18 -0
  32. package/src/BackendDrivers/BackendDriver.ts +31 -0
  33. package/src/BackendDrivers/CollaborationServerDriver.ts +318 -0
  34. package/src/BackendDrivers/DesktopFileDriver.ts +170 -0
  35. package/src/BackendDrivers/InMemoryFileDriver.ts +76 -0
  36. package/src/BackendDrivers/index.ts +4 -0
  37. package/src/ChangeManager.ts +148 -0
  38. package/src/LinearApolloDisplay/components/LinearApolloDisplay.tsx +248 -0
  39. package/src/LinearApolloDisplay/components/index.ts +1 -0
  40. package/src/LinearApolloDisplay/configSchema.ts +16 -0
  41. package/src/LinearApolloDisplay/glyphs/BoxGlyph.ts +422 -0
  42. package/src/LinearApolloDisplay/glyphs/CanonicalGeneGlyph.ts +1191 -0
  43. package/src/LinearApolloDisplay/glyphs/GenericChildGlyph.ts +151 -0
  44. package/src/LinearApolloDisplay/glyphs/Glyph.ts +382 -0
  45. package/src/LinearApolloDisplay/glyphs/ImplicitExonGeneGlyph.ts +697 -0
  46. package/src/LinearApolloDisplay/glyphs/index.ts +4 -0
  47. package/src/LinearApolloDisplay/index.ts +2 -0
  48. package/src/LinearApolloDisplay/stateModel/base.ts +146 -0
  49. package/src/LinearApolloDisplay/stateModel/getGlyph.ts +39 -0
  50. package/src/LinearApolloDisplay/stateModel/glyphs.ts +45 -0
  51. package/src/LinearApolloDisplay/stateModel/index.ts +20 -0
  52. package/src/LinearApolloDisplay/stateModel/layouts.ts +230 -0
  53. package/src/LinearApolloDisplay/stateModel/mouseEvents.ts +513 -0
  54. package/src/LinearApolloDisplay/stateModel/rendering.ts +441 -0
  55. package/src/LinearApolloDisplay/stateModel/trackHeightMixin.ts +43 -0
  56. package/src/LinearApolloDisplay/types.ts +1 -0
  57. package/src/OntologyManager/OntologyStore/__snapshots__/fulltext.test.ts.snap +208 -0
  58. package/src/OntologyManager/OntologyStore/__snapshots__/index.test.ts.snap +18846 -0
  59. package/src/OntologyManager/OntologyStore/fulltext-stopwords.ts +137 -0
  60. package/src/OntologyManager/OntologyStore/fulltext.test.ts +94 -0
  61. package/src/OntologyManager/OntologyStore/fulltext.ts +264 -0
  62. package/src/OntologyManager/OntologyStore/index.test.ts +130 -0
  63. package/src/OntologyManager/OntologyStore/index.ts +526 -0
  64. package/src/OntologyManager/OntologyStore/indexeddb-schema.ts +89 -0
  65. package/src/OntologyManager/OntologyStore/indexeddb-storage.ts +180 -0
  66. package/src/OntologyManager/OntologyStore/obo-graph-json-schema.ts +110 -0
  67. package/src/OntologyManager/OntologyStore/prefixes.ts +35 -0
  68. package/src/OntologyManager/index.ts +173 -0
  69. package/src/SixFrameFeatureDisplay/components/TrackLines.tsx +19 -0
  70. package/src/SixFrameFeatureDisplay/components/index.ts +1 -0
  71. package/src/SixFrameFeatureDisplay/configSchema.ts +21 -0
  72. package/src/SixFrameFeatureDisplay/index.ts +2 -0
  73. package/src/SixFrameFeatureDisplay/stateModel.ts +413 -0
  74. package/src/TabularEditor/HybridGrid/ChangeHandling.ts +88 -0
  75. package/src/TabularEditor/HybridGrid/Feature.tsx +346 -0
  76. package/src/TabularEditor/HybridGrid/FeatureAttributes.tsx +34 -0
  77. package/src/TabularEditor/HybridGrid/Highlight.tsx +40 -0
  78. package/src/TabularEditor/HybridGrid/HybridGrid.tsx +138 -0
  79. package/src/TabularEditor/HybridGrid/NumberCell.tsx +77 -0
  80. package/src/TabularEditor/HybridGrid/ToolBar.tsx +59 -0
  81. package/src/TabularEditor/HybridGrid/featureContextMenuItems.ts +119 -0
  82. package/src/TabularEditor/HybridGrid/index.ts +1 -0
  83. package/src/TabularEditor/TabularEditorPane.tsx +34 -0
  84. package/src/TabularEditor/index.ts +3 -0
  85. package/src/TabularEditor/model.ts +44 -0
  86. package/src/TabularEditor/types.ts +3 -0
  87. package/src/components/AddAssembly.tsx +464 -0
  88. package/src/components/AddChildFeature.tsx +247 -0
  89. package/src/components/AddFeature.tsx +252 -0
  90. package/src/components/CopyFeature.tsx +328 -0
  91. package/src/components/DeleteAssembly.tsx +185 -0
  92. package/src/components/DeleteFeature.tsx +90 -0
  93. package/src/components/Dialog.tsx +47 -0
  94. package/src/components/DownloadGFF3.tsx +213 -0
  95. package/src/components/ImportFeatures.tsx +295 -0
  96. package/src/components/ManageChecks.tsx +280 -0
  97. package/src/components/ManageUsers.tsx +218 -0
  98. package/src/components/ModifyFeatureAttribute.tsx +457 -0
  99. package/src/components/OntologyTermAutocomplete.tsx +240 -0
  100. package/src/components/OntologyTermMultiSelect.tsx +349 -0
  101. package/src/components/OpenLocalFile.tsx +178 -0
  102. package/src/components/ViewChangeLog.tsx +208 -0
  103. package/src/components/ViewCheckResults.tsx +151 -0
  104. package/src/components/index.ts +12 -0
  105. package/src/config.ts +10 -0
  106. package/src/declare.d.ts +3 -0
  107. package/src/extensions/annotationFromPileup.ts +208 -0
  108. package/src/extensions/index.ts +1 -0
  109. package/src/index.ts +394 -0
  110. package/src/makeDisplayComponent.tsx +244 -0
  111. package/src/session/ClientDataStore.ts +282 -0
  112. package/src/session/index.ts +1 -0
  113. package/src/session/session.ts +373 -0
  114. package/src/types.ts +10 -0
  115. package/src/util/index.ts +31 -0
  116. package/src/util/loadAssemblyIntoClient.ts +291 -0
@@ -0,0 +1,137 @@
1
+ /** set of words that should be ignored by fulltext indexing */
2
+ export const genericEnglishStopwords = new Set([
3
+ 'i',
4
+ 'me',
5
+ 'my',
6
+ 'myself',
7
+ 'we',
8
+ 'our',
9
+ 'ours',
10
+ 'ourselves',
11
+ 'you',
12
+ 'your',
13
+ 'yours',
14
+ 'yourself',
15
+ 'yourselves',
16
+ 'he',
17
+ 'him',
18
+ 'his',
19
+ 'himself',
20
+ 'she',
21
+ 'her',
22
+ 'hers',
23
+ 'herself',
24
+ 'it',
25
+ 'its',
26
+ 'itself',
27
+ 'they',
28
+ 'them',
29
+ 'their',
30
+ 'theirs',
31
+ 'themselves',
32
+ 'what',
33
+ 'which',
34
+ 'who',
35
+ 'whom',
36
+ 'this',
37
+ 'that',
38
+ 'these',
39
+ 'those',
40
+ 'am',
41
+ 'is',
42
+ 'are',
43
+ 'was',
44
+ 'were',
45
+ 'be',
46
+ 'been',
47
+ 'being',
48
+ 'have',
49
+ 'has',
50
+ 'had',
51
+ 'having',
52
+ 'do',
53
+ 'does',
54
+ 'did',
55
+ 'doing',
56
+ 'a',
57
+ 'an',
58
+ 'the',
59
+ 'and',
60
+ 'but',
61
+ 'if',
62
+ 'or',
63
+ 'because',
64
+ 'as',
65
+ 'until',
66
+ 'while',
67
+ 'of',
68
+ 'at',
69
+ 'by',
70
+ 'for',
71
+ 'with',
72
+ 'about',
73
+ 'against',
74
+ 'between',
75
+ 'into',
76
+ 'through',
77
+ 'during',
78
+ 'before',
79
+ 'after',
80
+ 'above',
81
+ 'below',
82
+ 'to',
83
+ 'from',
84
+ 'up',
85
+ 'down',
86
+ 'in',
87
+ 'out',
88
+ 'on',
89
+ 'off',
90
+ 'over',
91
+ 'under',
92
+ 'again',
93
+ 'further',
94
+ 'then',
95
+ 'once',
96
+ 'here',
97
+ 'there',
98
+ 'when',
99
+ 'where',
100
+ 'why',
101
+ 'how',
102
+ 'all',
103
+ 'any',
104
+ 'both',
105
+ 'each',
106
+ 'few',
107
+ 'more',
108
+ 'most',
109
+ 'other',
110
+ 'some',
111
+ 'such',
112
+ 'no',
113
+ 'nor',
114
+ 'not',
115
+ 'only',
116
+ 'own',
117
+ 'same',
118
+ 'so',
119
+ 'than',
120
+ 'too',
121
+ 'very',
122
+ 's',
123
+ 't',
124
+ 'can',
125
+ 'will',
126
+ 'just',
127
+ 'don',
128
+ 'should',
129
+ 'now',
130
+ ...'1234567890',
131
+ ])
132
+
133
+ /**
134
+ * The set of stopwords we use for fulltext indexing. Currently
135
+ * just generic English stopwords, but will likely be expanded over time.
136
+ */
137
+ export const stopwords = genericEnglishStopwords
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+
3
+ import {
4
+ PREFIXED_ID_PATH,
5
+ elaborateMatch,
6
+ extractWords,
7
+ getWords,
8
+ } from './fulltext'
9
+ import { OntologyDBNode } from './indexeddb-schema'
10
+
11
+ const testNode: OntologyDBNode = {
12
+ id: 'http://purl.obolibrary.org/obo/SO_0000001',
13
+ lbl: 'region',
14
+ type: 'CLASS',
15
+ meta: {
16
+ definition: {
17
+ val: 'A sequence_feature with an extent greater than zero. A nucleotide region is composed of bases and a polypeptide region is composed of amino acids. It may also be termed a region of some number of nucleotides.',
18
+ xrefs: ['SO:ke'],
19
+ },
20
+ subsets: ['http://purl.obolibrary.org/obo/so#SOFA'],
21
+ synonyms: [{ pred: 'hasExactSynonym', val: 'sequence' }],
22
+ basicPropertyValues: [
23
+ {
24
+ pred: 'http://www.geneontology.org/formats/oboInOwl#hasOBONamespace',
25
+ val: 'sequence',
26
+ },
27
+ ],
28
+ },
29
+ }
30
+
31
+ const prefixes = new Map<string, string>([
32
+ ['SO:', 'http://purl.obolibrary.org/obo/SO_'],
33
+ ])
34
+
35
+ describe('extractWords', () => {
36
+ it('can words from the members of objects', () => {
37
+ const result = extractWords(['bar baz', 'noggin'])
38
+ expect([...result]).toEqual(['bar', 'baz', 'noggin'])
39
+ })
40
+ it('can get the words from mix of stuff', () => {
41
+ const set = extractWords(['zoz-zoo', 'bar baz', 'noggin', 'twenty'])
42
+ expect([...set]).toEqual(['zoz', 'zoo', 'bar', 'baz', 'noggin', 'twenty'])
43
+ })
44
+ })
45
+
46
+ describe('getWords', () => {
47
+ it('can get the words from a test node', () => {
48
+ const result = getWords(
49
+ testNode,
50
+ [
51
+ PREFIXED_ID_PATH,
52
+ '$.lbl',
53
+ '$.meta.definition.val',
54
+ '$.meta.synonyms[*].val',
55
+ ],
56
+ prefixes,
57
+ )
58
+ expect([...result]).toMatchSnapshot()
59
+ })
60
+ })
61
+
62
+ describe('elaborateMatch', () => {
63
+ it('can do one', () => {
64
+ const result = elaborateMatch(
65
+ [
66
+ { displayName: 'label', jsonPath: '$.lbl' },
67
+ { displayName: 'synonym', jsonPath: '$.meta.synonyms[*].val' },
68
+ { displayName: 'definition', jsonPath: '$.meta.definition.val' },
69
+ ],
70
+ testNode,
71
+ new Set([1, 2]),
72
+ ['zonk', 'nucleotide', 'region'],
73
+ prefixes,
74
+ )
75
+ expect(result.length).toBe(1)
76
+ expect(result).toMatchSnapshot()
77
+ })
78
+ it('can do another', () => {
79
+ const result = elaborateMatch(
80
+ [
81
+ { displayName: 'ID', jsonPath: PREFIXED_ID_PATH },
82
+ { displayName: 'label', jsonPath: '$.lbl' },
83
+ { displayName: 'synonym', jsonPath: '$.meta.synonyms[*].val' },
84
+ { displayName: 'definition', jsonPath: '$.meta.definition.val' },
85
+ ],
86
+ testNode,
87
+ new Set([0]),
88
+ ['SO:0000001'],
89
+ prefixes,
90
+ )
91
+ expect(result.length).toBe(1)
92
+ expect(result).toMatchSnapshot()
93
+ })
94
+ })
@@ -0,0 +1,264 @@
1
+ /* eslint-disable import/no-named-as-default-member */
2
+ // jsonpath triggers this rule for some reason. import { query } from 'jsonpath' does not work
3
+
4
+ import { checkAbortSignal } from '@jbrowse/core/util'
5
+ import jsonpath from 'jsonpath'
6
+
7
+ import { stopwords } from './fulltext-stopwords'
8
+ import { OntologyDBNode } from './indexeddb-schema'
9
+ import { applyPrefixes } from './prefixes'
10
+ import OntologyStore, { Transaction } from '.'
11
+ import { TextIndexFieldDefinition } from '..'
12
+
13
+ /** special value of jsonPath that gets the IRI (that is, ID) of the node with the configured prefixes applied */
14
+ export const PREFIXED_ID_PATH = '$PREFIXED_ID'
15
+
16
+ /** small wrapper for jsonpath.query that intercepts requests for the special prefixed ID path */
17
+ function jsonPathQuery(
18
+ node: OntologyDBNode,
19
+ path: string,
20
+ prefixes: Map<string, string>,
21
+ ) {
22
+ if (path === PREFIXED_ID_PATH) {
23
+ return [applyPrefixes(node.id, prefixes)]
24
+ }
25
+ return jsonpath.query(node, path)
26
+ }
27
+
28
+ function wordsInString(str: string) {
29
+ return str
30
+ .toLowerCase()
31
+ .split(/[^\d:A-Za-z]+/)
32
+ .filter((word) => word && !stopwords.has(word))
33
+ }
34
+
35
+ /**
36
+ * recursively get the indexable words from an iterator
37
+ * of any objects
38
+ **/
39
+ export function* extractWords(
40
+ strings: Iterable<string>,
41
+ ): Generator<string, void, undefined> {
42
+ for (const str of strings) {
43
+ yield* wordsInString(str)
44
+ }
45
+ }
46
+
47
+ export function* extractStrings(
48
+ things: Iterable<unknown>,
49
+ ): Generator<string, void, undefined> {
50
+ for (const thing of things) {
51
+ if (typeof thing === 'string') {
52
+ yield thing
53
+ } else if (typeof thing === 'object') {
54
+ const members = jsonpath.query(thing, '$..*')
55
+ yield* extractStrings(members)
56
+ }
57
+ }
58
+ }
59
+
60
+ /** @returns generator of tuples of [jsonpath, word] */
61
+ export function* getWords(
62
+ node: OntologyDBNode,
63
+ jsonPaths: Iterable<string>,
64
+ prefixes: Map<string, string>,
65
+ ): Generator<[string, string], void, undefined> {
66
+ for (const path of jsonPaths) {
67
+ const queryResult = jsonPathQuery(node, path, prefixes) as unknown[]
68
+ if (queryResult.length > 0) {
69
+ for (const word of extractWords(extractStrings(queryResult))) {
70
+ yield [path, word]
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ export interface Match {
77
+ term: OntologyDBNode
78
+ field: TextIndexFieldDefinition
79
+ str: string
80
+ score: number
81
+ }
82
+
83
+ export function isMatch(thing: object): thing is Match {
84
+ return (
85
+ 'term' in thing && 'field' in thing && 'str' in thing && 'score' in thing
86
+ )
87
+ }
88
+
89
+ /**
90
+ *
91
+ **/
92
+ export async function textSearch(
93
+ this: OntologyStore,
94
+ text: string,
95
+ tx?: Transaction<['nodes']>,
96
+ signal?: AbortSignal,
97
+ ) {
98
+ const db = await this.db
99
+ const myTx = tx ?? db.transaction(['nodes'])
100
+
101
+ checkAbortSignal(signal)
102
+
103
+ const queryWords = [...wordsInString(text)]
104
+
105
+ const queries: Promise<void>[] = []
106
+
107
+ /**
108
+ * Build a structure of which terms match which words.
109
+ * This is a Map of term.id -\> Set\<query word number\>
110
+ **/
111
+ const initialMatches = new Map<string, [OntologyDBNode, Set<number>]>()
112
+
113
+ // find startsWith and complete matches
114
+ queries.push(
115
+ ...queryWords.map(async (queryWord, queryWordIndex) => {
116
+ checkAbortSignal(signal)
117
+ const idx = myTx.objectStore('nodes').index('full-text-words')
118
+ for await (const cursor of idx.iterate(
119
+ IDBKeyRange.bound(queryWord, `${queryWord}\uFFFF`, false, false),
120
+ )) {
121
+ checkAbortSignal(signal)
122
+ const term = cursor.value
123
+ const termMatches = initialMatches.get(term.id) ?? [
124
+ term,
125
+ new Set<number>(),
126
+ ]
127
+ termMatches[1].add(queryWordIndex)
128
+ initialMatches.set(term.id, termMatches)
129
+ }
130
+ }),
131
+ )
132
+
133
+ await Promise.all(queries)
134
+
135
+ checkAbortSignal(signal)
136
+
137
+ // now rank the term matches and add some detail
138
+ const results: Match[] = []
139
+ for (const [, [term, wordIndexes]] of initialMatches) {
140
+ checkAbortSignal(signal)
141
+ results.push(
142
+ ...elaborateMatch(
143
+ this.textIndexFields,
144
+ term,
145
+ wordIndexes,
146
+ queryWords,
147
+ this.prefixes,
148
+ ),
149
+ )
150
+ }
151
+
152
+ // sort the terms by score descending
153
+ results.sort((a, b) => b.score - a.score)
154
+
155
+ // truncate if necessary
156
+ return results.slice(
157
+ 0,
158
+ this.options.maxSearchResults ?? this.DEFAULT_MAX_SEARCH_RESULTS,
159
+ )
160
+ }
161
+
162
+ export function elaborateMatch(
163
+ textIndexPaths: TextIndexFieldDefinition[],
164
+ term: OntologyDBNode,
165
+ queryWordIndexes: Set<number>,
166
+ queryWords: string[],
167
+ prefixes: Map<string, string>,
168
+ ): Match[] {
169
+ const sortedWordIndexes = [...queryWordIndexes].sort()
170
+ const matchedQueryWords = sortedWordIndexes.map((i) => queryWords[i])
171
+ const queryWordRegexps = matchedQueryWords.map((queryWord) => {
172
+ const escaped = queryWord.replaceAll(/[$()*+./?[\\\]^{|}-]/g, '\\$&')
173
+ return new RegExp(`\\b${escaped}`, 'gi')
174
+ })
175
+ // const needle = matchedQueryWords.join(' ')
176
+
177
+ // ranking weights that can be tweaked if you know what you're doing
178
+ const FIELD_PRIORITY_WEIGHT = 1
179
+ const MATCH_WORDS_CLOSENESS_WEIGHT = 0.05
180
+ const MATCH_ADJACENCY_BONUS = 1
181
+ const MATCH_RIGHT_ORDER_BONUS = 1
182
+ const MATCH_LENGTH_WEIGHT = 0.01
183
+ const PCT_OF_STRING_WEIGHT = 0.05
184
+ const WORD_BONUS = 100 // bonus for each of the words matched
185
+
186
+ // inspect the node at each of the index paths, because we don't know which ones matched
187
+ interface WordMatch {
188
+ wordIndex: number
189
+ position: number
190
+ }
191
+ let matches: (Match & { wordMatches: WordMatch[] })[] = []
192
+ let maxScore = 0
193
+ for (const [fieldIdx, field] of textIndexPaths.entries()) {
194
+ const wordsMatched = new Set<number>()
195
+ const fieldPriorityBonus = textIndexPaths.length - fieldIdx - 1
196
+ const termStrings = [
197
+ ...extractStrings(jsonPathQuery(term, field.jsonPath, prefixes)),
198
+ ]
199
+ // find occurrences of each of the words in the strings
200
+ for (const str of termStrings) {
201
+ let score = 0
202
+ const wordMatches: WordMatch[] = []
203
+ for (const [wordIndex, re] of queryWordRegexps.entries()) {
204
+ for (const match of str.matchAll(re)) {
205
+ score += 1 + fieldPriorityBonus * FIELD_PRIORITY_WEIGHT
206
+ wordsMatched.add(wordIndex)
207
+ const position = match.index
208
+ const queryWord = queryWords[wordIndex]
209
+ if (position !== undefined) {
210
+ score += queryWord.length * MATCH_LENGTH_WEIGHT
211
+ score +=
212
+ (queryWord.length / str.length) * 100 * PCT_OF_STRING_WEIGHT
213
+ wordMatches.push({ wordIndex, position })
214
+ }
215
+ }
216
+ }
217
+
218
+ // apply the words-matched bonus
219
+ score += wordsMatched.size * WORD_BONUS
220
+
221
+ if (maxScore < score) {
222
+ maxScore = score
223
+ }
224
+ // sort the word matches by position in the target string ascending
225
+ wordMatches.sort((a, b) => a.position - b.position)
226
+ if (wordMatches.length > 0) {
227
+ matches.push({ term, field, str, score, wordMatches })
228
+ }
229
+ }
230
+ }
231
+
232
+ // Keep only the highest-scored matches. Usually 1, but there
233
+ // could be multiple if there is a tie for first place.
234
+ matches = matches.filter((m) => m.score === maxScore)
235
+
236
+ for (const match of matches) {
237
+ const { wordMatches } = match
238
+ // re-examine the word order and spacing to give bonuses for the
239
+ // right order and close spacing
240
+ for (let i = 0; i < wordMatches.length - 1; i++) {
241
+ // bonus for pairs with adjacent word indexes and close spacing
242
+ const m1 = wordMatches[i]
243
+ const m2 = wordMatches[i + 1]
244
+ const wdiff = m2.wordIndex - m1.wordIndex
245
+ if (wdiff === 1 || wdiff === -1) {
246
+ // they are adjacent, bonus
247
+ match.score += MATCH_ADJACENCY_BONUS
248
+ if (wdiff === 1) {
249
+ // they are in the right order, bonus
250
+ match.score += MATCH_RIGHT_ORDER_BONUS
251
+ }
252
+ // give additional bonus for how close they are
253
+ const spacing =
254
+ Math.abs(
255
+ m2.position -
256
+ (m1.position + matchedQueryWords[m1.wordIndex].length),
257
+ ) - 1
258
+ match.score -= spacing * MATCH_WORDS_CLOSENESS_WEIGHT
259
+ }
260
+ }
261
+ }
262
+
263
+ return matches
264
+ }
@@ -0,0 +1,130 @@
1
+ import path from 'node:path'
2
+
3
+ import { beforeAll, describe, expect, it, jest } from '@jest/globals'
4
+
5
+ import OntologyStore from '.'
6
+ import { OntologyClass, isOntologyClass } from '..'
7
+
8
+ jest.setTimeout(1_000_000_000)
9
+
10
+ const prefixes = new Map([
11
+ ['SO:', 'http://purl.obolibrary.org/obo/SO_'],
12
+ ['GO:', 'http://purl.obolibrary.org/obo/GO_'],
13
+ ])
14
+
15
+ // jsonpath uses an "obj instanceof Object" check in its "query", which fails in
16
+ // tests because the mocked indexedDb uses a different scope and thus a
17
+ // different "Object". This intercepts calls to "query" in this test and makes
18
+ // sure the main scope "Object" is used.
19
+ jest.mock('jsonpath', () => {
20
+ const original = jest.requireActual('jsonpath') as typeof import('jsonpath')
21
+ return {
22
+ ...original,
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ query: jest.fn((obj: any, pathExpression: string, count?: number) => {
25
+ const newObj =
26
+ obj instanceof Object ? obj : JSON.parse(JSON.stringify(obj))
27
+ return original.query(newObj, pathExpression, count)
28
+ }),
29
+ }
30
+ })
31
+
32
+ let so: OntologyStore
33
+
34
+ beforeAll(async () => {
35
+ const localPath = path.resolve(__dirname, '../../../test_data/so-v3.1.json')
36
+ so = new OntologyStore(
37
+ 'Sequence Ontology',
38
+ 'automated testing',
39
+ { locationType: 'LocalPathLocation', localPath },
40
+ { prefixes },
41
+ )
42
+ await so.db
43
+ })
44
+
45
+ describe('OntologyStore', () => {
46
+ it('can load goslim generic', async () => {
47
+ const localPath = path.resolve(
48
+ __dirname,
49
+ '../../../test_data/goslim_generic.json',
50
+ )
51
+ const goslimGeneric = new OntologyStore(
52
+ 'Gene Ontology',
53
+ 'automated testing',
54
+ { locationType: 'LocalPathLocation', localPath },
55
+ { prefixes },
56
+ )
57
+
58
+ expect(await goslimGeneric.termCount()).toMatchSnapshot()
59
+
60
+ expect(
61
+ await goslimGeneric.getTermsByFulltext('mitotic nuclear division'),
62
+ ).toMatchSnapshot()
63
+ })
64
+
65
+ it('can query SO', async () => {
66
+ expect(await so.termCount()).toMatchSnapshot()
67
+ })
68
+ it('can query SO gene terms and parts of genes', async () => {
69
+ const geneTerms = await so.getTermsWithLabelOrSynonym('gene')
70
+ expect(geneTerms).toMatchSnapshot()
71
+ expect(isOntologyClass(geneTerms[0])).toBe(true)
72
+ const geneParts = await so.getClassesThat(
73
+ 'part_of',
74
+ geneTerms as OntologyClass[],
75
+ )
76
+ expect(geneParts).toMatchSnapshot()
77
+ })
78
+ it('can query SO features not part of something else', async () => {
79
+ const topLevelClasses = await so.getClassesWithoutPropertyLabeled(
80
+ 'part_of',
81
+ { includeSubProperties: true },
82
+ )
83
+ expect(topLevelClasses.length).toMatchSnapshot()
84
+ expect(topLevelClasses.find((term) => term.lbl === 'mRNA')).toBeUndefined()
85
+ // gene is member_of gene_group, so also doesn't appear here. There doesn't seem to be
86
+ // clarification in SO for when a feature MUST be part_of.
87
+ expect(topLevelClasses.find((term) => term.lbl === 'gene')).toBeUndefined()
88
+ })
89
+ it('can expand subclasses of SO:000039 and still get SO:000039', async () => {
90
+ const expanded = so.expandSubclasses(
91
+ ['http://purl.obolibrary.org/obo/SO_0000039'],
92
+ 'is_a',
93
+ )
94
+
95
+ const ex = []
96
+ for await (const node of expanded) {
97
+ ex.push(node)
98
+ }
99
+ expect(ex.length).toBeGreaterThan(0)
100
+ expect(ex).toEqual(['http://purl.obolibrary.org/obo/SO_0000039'])
101
+ })
102
+ it('can query valid part_of for match', async () => {
103
+ const parentTypeTerms = await so.getTermsWithLabelOrSynonym('match', {
104
+ includeSubclasses: false,
105
+ })
106
+ // eslint-disable-next-line unicorn/no-array-callback-reference
107
+ const parentTypeClassTerms = parentTypeTerms.filter(isOntologyClass)
108
+ expect(parentTypeClassTerms).toMatchSnapshot()
109
+ const subpartTerms = await so.getClassesThat(
110
+ 'part_of',
111
+ parentTypeClassTerms,
112
+ )
113
+ expect(subpartTerms.length).toBeGreaterThan(0)
114
+ })
115
+
116
+ it('SO clone_insert_end is among valid subparts of BAC_cloned_genomic_insert', async () => {
117
+ const bcgi = await so.getTermsWithLabelOrSynonym(
118
+ 'BAC_cloned_genomic_insert',
119
+ { includeSubclasses: false },
120
+ )
121
+ // eslint-disable-next-line unicorn/no-array-callback-reference
122
+ const bcgiClass = bcgi.filter(isOntologyClass)
123
+ expect(bcgiClass.length).toBe(1)
124
+ expect(bcgiClass[0].lbl).toBe('BAC_cloned_genomic_insert')
125
+ const subpartTerms = await so.getClassesThat('part_of', bcgiClass)
126
+ expect(subpartTerms.length).toBeGreaterThan(0)
127
+ expect(subpartTerms.find((t) => t.lbl === 'clone_insert_end')).toBeTruthy()
128
+ expect(subpartTerms).toMatchSnapshot()
129
+ })
130
+ })