@apollo-annotation/jbrowse-plugin-apollo 0.3.6 → 0.3.7

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 (56) hide show
  1. package/dist/index.esm.js +2679 -850
  2. package/dist/index.esm.js.map +1 -1
  3. package/dist/jbrowse-plugin-apollo.cjs.development.js +2676 -847
  4. package/dist/jbrowse-plugin-apollo.cjs.development.js.map +1 -1
  5. package/dist/jbrowse-plugin-apollo.cjs.production.min.js +1 -1
  6. package/dist/jbrowse-plugin-apollo.cjs.production.min.js.map +1 -1
  7. package/dist/jbrowse-plugin-apollo.umd.development.js +5194 -1258
  8. package/dist/jbrowse-plugin-apollo.umd.development.js.map +1 -1
  9. package/dist/jbrowse-plugin-apollo.umd.production.min.js +1 -1
  10. package/dist/jbrowse-plugin-apollo.umd.production.min.js.map +1 -1
  11. package/package.json +4 -4
  12. package/src/ApolloInternetAccount/addMenuItems.ts +18 -0
  13. package/src/ChangeManager.ts +10 -6
  14. package/src/FeatureDetailsWidget/Attributes.tsx +8 -3
  15. package/src/FeatureDetailsWidget/TranscriptSequence.tsx +12 -20
  16. package/src/FeatureDetailsWidget/TranscriptWidgetEditLocation.tsx +929 -175
  17. package/src/FeatureDetailsWidget/TranscriptWidgetSummary.tsx +4 -0
  18. package/src/LinearApolloDisplay/components/LinearApolloDisplay.tsx +1 -1
  19. package/src/LinearApolloDisplay/glyphs/BoxGlyph.ts +48 -60
  20. package/src/LinearApolloDisplay/glyphs/GeneGlyph.ts +244 -51
  21. package/src/LinearApolloDisplay/glyphs/GenericChildGlyph.ts +46 -1
  22. package/src/LinearApolloDisplay/glyphs/Glyph.ts +9 -1
  23. package/src/LinearApolloDisplay/stateModel/base.ts +29 -0
  24. package/src/LinearApolloDisplay/stateModel/mouseEvents.ts +51 -35
  25. package/src/LinearApolloDisplay/stateModel/rendering.ts +2 -1
  26. package/src/LinearApolloSixFrameDisplay/components/LinearApolloSixFrameDisplay.tsx +7 -2
  27. package/src/LinearApolloSixFrameDisplay/components/TrackLines.tsx +12 -20
  28. package/src/LinearApolloSixFrameDisplay/glyphs/GeneGlyph.ts +243 -124
  29. package/src/LinearApolloSixFrameDisplay/stateModel/base.ts +42 -1
  30. package/src/LinearApolloSixFrameDisplay/stateModel/layouts.ts +19 -3
  31. package/src/LinearApolloSixFrameDisplay/stateModel/mouseEvents.ts +53 -34
  32. package/src/LinearApolloSixFrameDisplay/stateModel/rendering.ts +4 -2
  33. package/src/OntologyManager/index.ts +4 -1
  34. package/src/TabularEditor/HybridGrid/Feature.tsx +4 -0
  35. package/src/TabularEditor/HybridGrid/featureContextMenuItems.ts +108 -16
  36. package/src/components/AddAssemblyAliases.tsx +114 -0
  37. package/src/components/AddChildFeature.tsx +3 -6
  38. package/src/components/AddFeature.tsx +14 -15
  39. package/src/components/CopyFeature.tsx +2 -4
  40. package/src/components/CreateApolloAnnotation.tsx +334 -151
  41. package/src/components/DeleteFeature.tsx +358 -11
  42. package/src/components/DownloadGFF3.tsx +20 -1
  43. package/src/components/FilterTranscripts.tsx +86 -0
  44. package/src/components/MergeExons.tsx +193 -0
  45. package/src/components/MergeTranscripts.tsx +185 -0
  46. package/src/components/SplitExon.tsx +134 -0
  47. package/src/components/index.ts +3 -0
  48. package/src/config.ts +5 -0
  49. package/src/extensions/annotationFromJBrowseFeature.ts +2 -0
  50. package/src/extensions/annotationFromPileup.ts +99 -89
  51. package/src/session/session.ts +26 -13
  52. package/src/util/annotationFeatureUtils.ts +65 -0
  53. package/src/util/copyToClipboard.ts +21 -0
  54. package/src/util/glyphUtils.ts +49 -0
  55. package/src/util/index.ts +2 -0
  56. package/src/util/mouseEventsUtils.ts +113 -0
@@ -4,21 +4,30 @@
4
4
  /* eslint-disable @typescript-eslint/no-unsafe-call */
5
5
  /* eslint-disable @typescript-eslint/no-unsafe-return */
6
6
  import { type AnnotationFeatureSnapshot } from '@apollo-annotation/mst'
7
- import { AddFeatureChange } from '@apollo-annotation/shared'
8
7
  import { type Assembly } from '@jbrowse/core/assemblyManager/assembly'
9
8
  import { type DisplayType } from '@jbrowse/core/pluggableElementTypes'
10
9
  import type PluggableElementBase from '@jbrowse/core/pluggableElementTypes/PluggableElementBase'
11
- import { getContainingView, getSession } from '@jbrowse/core/util'
10
+ import {
11
+ type AbstractSessionModel,
12
+ getContainingView,
13
+ getSession,
14
+ } from '@jbrowse/core/util'
12
15
  import { type LinearGenomeViewModel } from '@jbrowse/plugin-linear-genome-view'
13
16
  import AddIcon from '@mui/icons-material/Add'
14
17
  import ObjectID from 'bson-objectid'
15
18
 
16
- import { type ApolloSessionModel } from '../session'
19
+ import { CreateApolloAnnotation } from '../components/CreateApolloAnnotation'
17
20
 
18
- function parseCigar(cigar: string): [string | undefined, number][] {
19
- return (cigar.toUpperCase().match(/\d+\D/g) ?? []).map((op) => {
20
- return [(/\D/.exec(op) ?? [])[0], Number.parseInt(op, 10)]
21
- })
21
+ function parseCigar(cigar: string): [string, number][] {
22
+ const regex = /(\d+)([MIDNSHPX=])/g
23
+ const result: [string, number][] = []
24
+ let match
25
+
26
+ while ((match = regex.exec(cigar)) !== null) {
27
+ result.push([match[2], Number.parseInt(match[1], 10)])
28
+ }
29
+
30
+ return result
22
31
  }
23
32
 
24
33
  export function annotationFromPileup(pluggableElement: PluggableElementBase) {
@@ -62,55 +71,87 @@ export function annotationFromPileup(pluggableElement: PluggableElementBase) {
62
71
  }
63
72
  return refSeqId
64
73
  },
65
- createFeature() {
74
+ getAnnotationFeature() {
66
75
  const feature = self.contextMenuFeature
67
76
  const assembly = self.getAssembly()
68
77
  const refSeqId = self.getRefSeqId(assembly)
78
+ const start: number = feature.get('start')
79
+ const end: number = feature.get('end')
80
+ const strand = feature.get('strand')
81
+ const name = feature.get('name')
82
+
69
83
  const cigarData: string = feature.get('CIGAR')
70
84
  const ops = parseCigar(cigarData)
71
- let currOffset = 0
72
- const start: number = feature.get('start')
73
- let openStart: number | undefined
85
+ let position = start
86
+ let currentExonStart: number | undefined
74
87
  const exons: {
75
88
  start: number
76
89
  end: number
77
90
  }[] = []
91
+
92
+ // Example: [[96,S], [4,M], [4216,N], [357,M], [1,I], [628,M], [94,S]]
93
+ // Results in 2 exons
94
+ // M, = and X are matches -> exon
95
+ // N is a gap in the reference sequence -> intron
96
+ // I, S, H and P -> not counted in reference position
78
97
  for (const [op, len] of ops) {
79
- // open or continue open
80
- if (op === 'M' || op === '=') {
81
- // if it was closed, then open with start, strand, type
82
- if (openStart === undefined) {
83
- // add subfeature
84
- openStart = currOffset + start
98
+ switch (op) {
99
+ case 'M':
100
+ case '=':
101
+ case 'X': {
102
+ if (currentExonStart === undefined) {
103
+ currentExonStart = position
104
+ }
105
+ position += len
106
+ break
107
+ }
108
+
109
+ case 'N': {
110
+ if (currentExonStart !== undefined) {
111
+ exons.push({
112
+ start: currentExonStart,
113
+ end: position,
114
+ })
115
+ currentExonStart = undefined
116
+ }
117
+ position += len
118
+ break
119
+ }
120
+ case 'D': {
121
+ position += len
122
+ break
123
+ }
124
+ case 'I':
125
+ case 'S':
126
+ case 'H':
127
+ case 'P': {
128
+ // These operations do not affect the position in the reference sequence
129
+ break
130
+ }
131
+ default: {
132
+ throw new Error(`Unknown CIGAR operation: ${op}`)
85
133
  }
86
- } else if (op === 'N' && openStart !== undefined) {
87
- // if it was open, then close and add the subfeature
88
- exons.push({
89
- start: openStart,
90
- end: currOffset + openStart,
91
- })
92
- openStart = undefined
93
- }
94
- if (op !== 'I') {
95
- // we ignore insertions when calculating potential exon length
96
- currOffset += len
97
134
  }
98
135
  }
99
- // if we are still open, then close with the final length and add subfeature
100
- if (openStart !== undefined) {
136
+
137
+ // If still in exon at end
138
+ if (currentExonStart !== undefined) {
101
139
  exons.push({
102
- start: openStart,
103
- end: currOffset + start,
140
+ start: currentExonStart,
141
+ end: position,
104
142
  })
105
143
  }
106
144
 
107
145
  const newFeature: AnnotationFeatureSnapshot = {
108
146
  _id: ObjectID().toHexString(),
109
147
  refSeq: refSeqId,
110
- min: feature.get('start'),
111
- max: feature.get('end'),
148
+ min: start,
149
+ max: end,
112
150
  type: 'mRNA',
113
- strand: feature.get('strand'),
151
+ strand,
152
+ attributes: {
153
+ name: [name],
154
+ },
114
155
  }
115
156
  if (exons.length === 0) {
116
157
  return newFeature
@@ -118,75 +159,28 @@ export function annotationFromPileup(pluggableElement: PluggableElementBase) {
118
159
 
119
160
  const children: Record<string, AnnotationFeatureSnapshot> = {}
120
161
  newFeature.children = children
121
- const [firstExon] = exons
122
- const cdsFeature: AnnotationFeatureSnapshot = {
123
- _id: ObjectID().toHexString(),
124
- refSeq: refSeqId,
125
- min: firstExon.start,
126
- max: firstExon.end,
127
- type: 'CDS',
128
- strand: feature.get('strand'),
129
- }
130
- newFeature.children[cdsFeature._id] = cdsFeature
131
- if (exons.length === 1) {
132
- const exon: AnnotationFeatureSnapshot = {
133
- _id: ObjectID().toHexString(),
134
- refSeq: refSeqId,
135
- min: firstExon.start,
136
- max: firstExon.end,
137
- type: 'exon',
138
- strand: feature.get('strand'),
139
- }
140
- newFeature.children[exon._id] = exon
141
- return newFeature
142
- }
143
162
 
144
- const discontinuousLocations: {
145
- start: number
146
- end: number
147
- phase: 0 | 1 | 2
148
- }[] = []
149
- let phase: 0 | 1 | 2 = 0
150
163
  for (const exon of exons) {
151
- cdsFeature.min = Math.min(cdsFeature.min, exon.start)
152
- cdsFeature.max = Math.max(cdsFeature.max, exon.end)
153
- const { end, start } = exon
154
- discontinuousLocations.push({ start, end, phase })
155
- const localPhase = (end - start) % 3
156
- phase = ((phase + localPhase) % 3) as 0 | 1 | 2
157
164
  const newExon: AnnotationFeatureSnapshot = {
158
165
  _id: ObjectID().toHexString(),
159
166
  refSeq: refSeqId,
160
- min: start,
161
- max: end,
167
+ min: exon.start,
168
+ max: exon.end,
162
169
  type: 'exon',
163
- strand: feature.get('strand'),
170
+ strand,
164
171
  }
165
172
  newFeature.children[newExon._id] = newExon
166
173
  }
167
174
  return newFeature
168
175
  },
169
- async onPileupFeatureContext() {
170
- const newFeature = self.createFeature()
171
- const assembly = self.getAssembly()
172
- const assemblyId = assembly.name
173
- const change = new AddFeatureChange({
174
- changedIds: [newFeature._id],
175
- typeName: 'AddFeatureChange',
176
- assembly: assemblyId,
177
- addedFeature: newFeature,
178
- })
179
- const session = getSession(self)
180
- await (
181
- session as unknown as ApolloSessionModel
182
- ).apolloDataStore.changeManager.submit(change)
183
- session.notify('Annotation added successfully', 'success')
184
- },
185
176
  }))
186
177
  .views((self) => {
187
178
  const superContextMenuItems = self.contextMenuItems
188
179
  return {
189
180
  contextMenuItems() {
181
+ const session = getSession(self)
182
+ const assembly = self.getAssembly()
183
+ const region = self.getFirstRegion()
190
184
  const feature = self.contextMenuFeature
191
185
  if (!feature) {
192
186
  return superContextMenuItems()
@@ -196,7 +190,23 @@ export function annotationFromPileup(pluggableElement: PluggableElementBase) {
196
190
  {
197
191
  label: 'Create Apollo annotation',
198
192
  icon: AddIcon,
199
- onClick: self.onPileupFeatureContext,
193
+ onClick: () => {
194
+ ;(session as unknown as AbstractSessionModel).queueDialog(
195
+ (doneCallback) => [
196
+ CreateApolloAnnotation,
197
+ {
198
+ session,
199
+ handleClose: () => {
200
+ doneCallback()
201
+ },
202
+ annotationFeature: self.getAnnotationFeature(assembly),
203
+ assembly,
204
+ refSeqId: self.getRefSeqId(assembly),
205
+ region,
206
+ },
207
+ ],
208
+ )
209
+ },
200
210
  },
201
211
  ]
202
212
  },
@@ -36,6 +36,7 @@ import {
36
36
  import { type ApolloInternetAccountModel } from '../ApolloInternetAccount/model'
37
37
  import { ApolloJobModel } from '../ApolloJobModel'
38
38
  import { type ChangeManager } from '../ChangeManager'
39
+ import type ApolloPluginConfigurationSchema from '../config'
39
40
  import { type ApolloRootModel } from '../types'
40
41
  import { createFetchErrorMessage } from '../util'
41
42
 
@@ -185,15 +186,6 @@ export function extendSession(
185
186
  }))
186
187
  .actions((self) => ({
187
188
  afterCreate: flow(function* afterCreate() {
188
- // When the initial config.json loads, it doesn't include the Apollo
189
- // tracks, which would result in a potentially invalid session snapshot
190
- // if any tracks are open. Here we copy the session snapshot, apply an
191
- // empty session snapshot, and then restore the original session
192
- // snapshot after the updated config.json loads.
193
- const sessionSnapshot = getSnapshot(self)
194
- const { id, name } = sessionSnapshot
195
- applySnapshot(self, { name, id })
196
- const { internetAccounts, jbrowse } = getRoot<ApolloRootModel>(self)
197
189
  autorun(
198
190
  () => {
199
191
  // broadcastLocations() // **** This is not working and therefore we need to duplicate broadcastLocations() -method code here because autorun() does not observe changes otherwise
@@ -254,7 +246,29 @@ export function extendSession(
254
246
  },
255
247
  { name: 'ApolloSession' },
256
248
  )
257
- // END AUTORUN
249
+ // When the initial config.json loads, it doesn't include the Apollo
250
+ // tracks, which would result in a potentially invalid session snapshot
251
+ // if any tracks are open. Here we copy the session snapshot, apply an
252
+ // empty session snapshot, and then restore the original session
253
+ // snapshot after the updated config.json loads.
254
+ // @ts-expect-error type is missing on ApolloRootModel
255
+ const { internetAccounts, jbrowse, reloadPluginManagerCallback } =
256
+ getRoot<ApolloRootModel>(self)
257
+ const pluginConfiguration =
258
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
259
+ jbrowse.configuration.ApolloPlugin as Instance<
260
+ typeof ApolloPluginConfigurationSchema
261
+ >
262
+ const hasRole = readConfObject(
263
+ pluginConfiguration,
264
+ 'hasRole',
265
+ ) as boolean
266
+ if (hasRole) {
267
+ return
268
+ }
269
+ const sessionSnapshot = getSnapshot(self)
270
+ const { id, name } = sessionSnapshot
271
+ applySnapshot(self, { name, id })
258
272
 
259
273
  // fetch and initialize assemblies for each of our Apollo internet accounts
260
274
  for (const internetAccount of internetAccounts as ApolloInternetAccountModel[]) {
@@ -290,9 +304,8 @@ export function extendSession(
290
304
  console.error(error)
291
305
  continue
292
306
  }
293
- applySnapshot(jbrowse, jbrowseConfig)
294
- // @ts-expect-error snapshot seems to get wrong type?
295
- applySnapshot(self, sessionSnapshot)
307
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
308
+ reloadPluginManagerCallback(jbrowseConfig, sessionSnapshot)
296
309
  }
297
310
  }),
298
311
  beforeDestroy() {
@@ -1,5 +1,7 @@
1
1
  import { type AnnotationFeature } from '@apollo-annotation/mst'
2
2
 
3
+ import { type MousePosition } from '../LinearApolloDisplay/stateModel/mouseEvents'
4
+
3
5
  export function getFeatureName(feature: AnnotationFeature) {
4
6
  const { attributes } = feature
5
7
  const name = attributes.get('gff_name')
@@ -51,3 +53,66 @@ export function getStrand(strand: number | undefined) {
51
53
  }
52
54
  return ''
53
55
  }
56
+
57
+ function getChildren(feature: AnnotationFeature): AnnotationFeature[] {
58
+ const children: AnnotationFeature[] = []
59
+ //
60
+ if (feature.children) {
61
+ for (const [, ff] of feature.children) {
62
+ children.push(ff)
63
+ }
64
+ }
65
+ return children
66
+ }
67
+
68
+ function getParents(feature: AnnotationFeature): AnnotationFeature[] {
69
+ const parents: AnnotationFeature[] = []
70
+ let { parent } = feature
71
+ while (parent) {
72
+ parents.push(parent)
73
+ ;({ parent } = parent)
74
+ }
75
+ return parents
76
+ }
77
+
78
+ export function getFeaturesUnderClick(
79
+ mousePosition: MousePosition,
80
+ includeSiblings = false,
81
+ ): AnnotationFeature[] {
82
+ const clickedFeatures: AnnotationFeature[] = []
83
+ if (!mousePosition.featureAndGlyphUnderMouse) {
84
+ return clickedFeatures
85
+ }
86
+ clickedFeatures.push(mousePosition.featureAndGlyphUnderMouse.feature)
87
+ for (const x of getParents(mousePosition.featureAndGlyphUnderMouse.feature)) {
88
+ clickedFeatures.push(x)
89
+ }
90
+ const { bp } = mousePosition
91
+ const children = getChildren(mousePosition.featureAndGlyphUnderMouse.feature)
92
+ for (const child of children) {
93
+ if (child.min < bp && child.max >= bp) {
94
+ clickedFeatures.push(child)
95
+ }
96
+ }
97
+ if (!includeSiblings) {
98
+ return clickedFeatures
99
+ }
100
+
101
+ // Also add siblings , i.e. features having the same parent as the clicked
102
+ // one and intersecting the click position
103
+ if (mousePosition.featureAndGlyphUnderMouse.feature.parent) {
104
+ const siblings =
105
+ mousePosition.featureAndGlyphUnderMouse.feature.parent.children
106
+ if (siblings) {
107
+ for (const [, sib] of siblings) {
108
+ if (sib._id == mousePosition.featureAndGlyphUnderMouse.feature._id) {
109
+ continue
110
+ }
111
+ if (sib.min < bp && sib.max >= bp) {
112
+ clickedFeatures.push(sib)
113
+ }
114
+ }
115
+ }
116
+ }
117
+ return clickedFeatures
118
+ }
@@ -0,0 +1,21 @@
1
+ export async function copyToClipboard(element: HTMLElement) {
2
+ if (isSecureContext) {
3
+ const textBlob = new Blob([element.outerText], { type: 'text/plain' })
4
+ const htmlBlob = new Blob([element.outerHTML], { type: 'text/html' })
5
+ const clipboardItem = new ClipboardItem({
6
+ [textBlob.type]: textBlob,
7
+ [htmlBlob.type]: htmlBlob,
8
+ })
9
+ return navigator.clipboard.write([clipboardItem])
10
+ }
11
+ const copyCallback = (event: ClipboardEvent) => {
12
+ event.clipboardData?.setData('text/plain', element.outerText)
13
+ event.clipboardData?.setData('text/html', element.outerHTML)
14
+ event.preventDefault()
15
+ }
16
+ document.addEventListener('copy', copyCallback)
17
+ // fall back to deprecated only in non-secure contexts
18
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
19
+ document.execCommand('copy')
20
+ document.removeEventListener('copy', copyCallback)
21
+ }
@@ -0,0 +1,49 @@
1
+ import {
2
+ type AnnotationFeature,
3
+ type TranscriptPartCoding,
4
+ } from '@apollo-annotation/mst'
5
+ import { type LinearGenomeViewModel } from '@jbrowse/plugin-linear-genome-view'
6
+
7
+ export function getMinAndMaxPx(
8
+ feature: AnnotationFeature | TranscriptPartCoding,
9
+ refName: string,
10
+ regionNumber: number,
11
+ lgv: LinearGenomeViewModel,
12
+ ): [number, number] | undefined {
13
+ const minPxInfo = lgv.bpToPx({
14
+ refName,
15
+ coord: feature.min,
16
+ regionNumber,
17
+ })
18
+ const maxPxInfo = lgv.bpToPx({
19
+ refName,
20
+ coord: feature.max,
21
+ regionNumber,
22
+ })
23
+ if (minPxInfo === undefined || maxPxInfo === undefined) {
24
+ return
25
+ }
26
+ const { offsetPx } = lgv
27
+ const minPx = minPxInfo.offsetPx - offsetPx
28
+ const maxPx = maxPxInfo.offsetPx - offsetPx
29
+ return [minPx, maxPx]
30
+ }
31
+
32
+ export function getOverlappingEdge(
33
+ feature: AnnotationFeature,
34
+ x: number,
35
+ minMax: [number, number],
36
+ ): { feature: AnnotationFeature; edge: 'min' | 'max' } | undefined {
37
+ const [minPx, maxPx] = minMax
38
+ // Feature is too small to tell if we're overlapping an edge
39
+ if (Math.abs(maxPx - minPx) < 8) {
40
+ return
41
+ }
42
+ if (Math.abs(minPx - x) < 4) {
43
+ return { feature, edge: 'min' }
44
+ }
45
+ if (Math.abs(maxPx - x) < 4) {
46
+ return { feature, edge: 'max' }
47
+ }
48
+ return
49
+ }
package/src/util/index.ts CHANGED
@@ -30,3 +30,5 @@ export function getApolloInternetAccount(session: ApolloSessionModel) {
30
30
 
31
31
  export * from './loadAssemblyIntoClient'
32
32
  export * from './annotationFeatureUtils'
33
+ export * from './glyphUtils'
34
+ export * from './mouseEventsUtils'
@@ -0,0 +1,113 @@
1
+ import { type AnnotationFeature } from '@apollo-annotation/mst'
2
+
3
+ type MinEdge = 'min'
4
+ type MaxEdge = 'max'
5
+ export type Edge = MinEdge | MaxEdge
6
+
7
+ interface LocationChange {
8
+ featureId: string
9
+ oldLocation: number
10
+ newLocation: number
11
+ }
12
+
13
+ function expandFeatures(
14
+ feature: AnnotationFeature,
15
+ newLocation: number,
16
+ edge: Edge,
17
+ ): LocationChange[] {
18
+ const featureId = feature._id
19
+ const oldLocation = feature[edge]
20
+ const changes: LocationChange[] = [{ featureId, oldLocation, newLocation }]
21
+ const { parent } = feature
22
+ if (
23
+ parent &&
24
+ ((edge === 'min' && parent[edge] > newLocation) ||
25
+ (edge === 'max' && parent[edge] < newLocation))
26
+ ) {
27
+ changes.push(...expandFeatures(parent, newLocation, edge))
28
+ }
29
+ return changes
30
+ }
31
+
32
+ function shrinkFeatures(
33
+ feature: AnnotationFeature,
34
+ newLocation: number,
35
+ edge: Edge,
36
+ shrinkParent: boolean,
37
+ childIdToSkip?: string,
38
+ ): LocationChange[] {
39
+ const featureId = feature._id
40
+ const oldLocation = feature[edge]
41
+ const changes: LocationChange[] = [{ featureId, oldLocation, newLocation }]
42
+ const { parent, children } = feature
43
+ if (children) {
44
+ for (const [, child] of children) {
45
+ if (child._id === childIdToSkip) {
46
+ continue
47
+ }
48
+ if (
49
+ (edge === 'min' && child[edge] < newLocation) ||
50
+ (edge === 'max' && child[edge] > newLocation)
51
+ ) {
52
+ changes.push(...shrinkFeatures(child, newLocation, edge, shrinkParent))
53
+ }
54
+ }
55
+ }
56
+ if (parent && shrinkParent) {
57
+ const siblings: AnnotationFeature[] = []
58
+ if (parent.children) {
59
+ for (const [, c] of parent.children) {
60
+ if (c._id === featureId) {
61
+ continue
62
+ }
63
+ siblings.push(c)
64
+ }
65
+ }
66
+ if (siblings.length === 0) {
67
+ changes.push(
68
+ ...shrinkFeatures(parent, newLocation, edge, shrinkParent, featureId),
69
+ )
70
+ } else {
71
+ const oldLocation = parent[edge]
72
+ const boundedLocation = Math[edge](
73
+ ...siblings.map((s) => s[edge]),
74
+ newLocation,
75
+ )
76
+ if (boundedLocation !== oldLocation) {
77
+ changes.push(
78
+ ...shrinkFeatures(
79
+ parent,
80
+ boundedLocation,
81
+ edge,
82
+ shrinkParent,
83
+ featureId,
84
+ ),
85
+ )
86
+ }
87
+ }
88
+ }
89
+ return changes
90
+ }
91
+
92
+ export function getPropagatedLocationChanges(
93
+ feature: AnnotationFeature,
94
+ newLocation: number,
95
+ edge: Edge,
96
+ shrinkParent = false,
97
+ ): LocationChange[] {
98
+ const oldLocation = feature[edge]
99
+ if (newLocation === oldLocation) {
100
+ throw new Error(`New and existing locations are the same: "${newLocation}"`)
101
+ }
102
+ if (edge === 'min') {
103
+ if (newLocation > oldLocation) {
104
+ // shrinking feature, may need to shrink children and/or parents
105
+ return shrinkFeatures(feature, newLocation, edge, shrinkParent)
106
+ }
107
+ return expandFeatures(feature, newLocation, edge)
108
+ }
109
+ if (newLocation < oldLocation) {
110
+ return shrinkFeatures(feature, newLocation, edge, shrinkParent)
111
+ }
112
+ return expandFeatures(feature, newLocation, edge)
113
+ }