Skip to content

Commit

Permalink
Save track data
Browse files Browse the repository at this point in the history
Convert to AbstractTrackModel

Fix strand for gff3 save track data (#3688)
  • Loading branch information
cmdcolin committed Aug 22, 2023
1 parent ed402c8 commit 1de51c9
Show file tree
Hide file tree
Showing 22 changed files with 748 additions and 80 deletions.
65 changes: 65 additions & 0 deletions packages/core/assemblyManager/loadRefNameMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { BaseOptions, checkRefName, RefNameAliases } from './util'
import RpcManager from '../rpc/RpcManager'
import { when } from '../util'

export interface BasicRegion {
start: number
end: number
refName: string
assemblyName: string
}

export async function loadRefNameMap(
assembly: {
name: string
regions: BasicRegion[] | undefined
refNameAliases: RefNameAliases | undefined
getCanonicalRefName: (arg: string) => string
rpcManager: RpcManager
},
adapterConfig: unknown,
options: BaseOptions,
signal?: AbortSignal,
) {
const { sessionId } = options
await when(() => !!(assembly.regions && assembly.refNameAliases), {
signal,
name: 'when assembly ready',
})

const refNames = (await assembly.rpcManager.call(
sessionId,
'CoreGetRefNames',
{
adapterConfig,
signal,
...options,
},
{ timeout: 1000000 },
)) as string[]

const { refNameAliases } = assembly
if (!refNameAliases) {
throw new Error(`error loading assembly ${assembly.name}'s refNameAliases`)
}

const refNameMap = Object.fromEntries(
refNames.map(name => {
checkRefName(name)
return [assembly.getCanonicalRefName(name), name]
}),
)

// make the reversed map too
const reversed = Object.fromEntries(
Object.entries(refNameMap).map(([canonicalName, adapterName]) => [
adapterName,
canonicalName,
]),
)

return {
forwardMap: refNameMap,
reverseMap: reversed,
}
}
70 changes: 70 additions & 0 deletions packages/core/assemblyManager/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { AnyConfigurationModel } from '../configuration'
import jsonStableStringify from 'json-stable-stringify'
import { BaseRefNameAliasAdapter } from '../data_adapters/BaseAdapter'
import PluginManager from '../PluginManager'
import { BasicRegion } from './loadRefNameMap'

export type RefNameAliases = Record<string, string>

export interface BaseOptions {
signal?: AbortSignal
sessionId: string
statusCallback?: Function
}

export async function getRefNameAliases(
config: AnyConfigurationModel,
pm: PluginManager,
signal?: AbortSignal,
) {
const type = pm.getAdapterType(config.type)
const CLASS = await type.getAdapterClass()
const adapter = new CLASS(config, undefined, pm) as BaseRefNameAliasAdapter
return adapter.getRefNameAliases({ signal })
}

export async function getCytobands(
config: AnyConfigurationModel,
pm: PluginManager,
) {
const type = pm.getAdapterType(config.type)
const CLASS = await type.getAdapterClass()
const adapter = new CLASS(config, undefined, pm)

// @ts-expect-error
return adapter.getData()
}

export async function getAssemblyRegions(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assembly: any,
adapterConfig: AnyConfigurationModel,
signal?: AbortSignal,
): Promise<BasicRegion[]> {
const sessionId = 'loadRefNames'
return assembly.rpcManager.call(
sessionId,
'CoreGetRegions',
{
adapterConfig,
sessionId,
signal,
},
{ timeout: 1000000 },
)
}

const refNameRegex = new RegExp(
'[0-9A-Za-z!#$%&+./:;?@^_|~-][0-9A-Za-z!#$%&*+./:;=?@^_|~-]*',
)

// Valid refName pattern from https://samtools.github.io/hts-specs/SAMv1.pdf
export function checkRefName(refName: string) {
if (!refNameRegex.test(refName)) {
throw new Error(`Encountered invalid refName: "${refName}"`)
}
}

export function getAdapterId(adapterConf: unknown) {
return jsonStableStringify(adapterConf)
}
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"dompurify": "^3.0.0",
"escape-html": "^1.0.3",
"fast-deep-equal": "^3.1.3",
"file-saver": "^2.0.0",
"generic-filehandle": "^3.0.0",
"http-range-fetcher": "^2.0.0",
"is-object": "^1.0.1",
Expand Down
15 changes: 15 additions & 0 deletions packages/core/pluggableElementTypes/models/BaseTrackModel.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { lazy } from 'react'
import { transaction } from 'mobx'
import {
getRoot,
Expand All @@ -16,10 +17,14 @@ import {
} from '../../configuration'
import PluginManager from '../../PluginManager'
import { MenuItem } from '../../ui'
import { Save } from '../../ui/Icons'
import { getContainingView, getEnv, getSession } from '../../util'
import { isSessionModelWithConfigEditing } from '../../util/types'
import { ElementId } from '../../util/types/mst'

// lazies
const SaveTrackDataDlg = lazy(() => import('./components/SaveTrackData'))

export function getCompatibleDisplays(self: IAnyStateTreeNode) {
const { pluginManager } = getEnv(self)
const view = getContainingView(self)
Expand Down Expand Up @@ -211,6 +216,16 @@ export function createBaseTrackModel(

return [
...menuItems,
{
label: 'Save track data',
icon: Save,
onClick: () => {
getSession(self).queueDialog(handleClose => [
SaveTrackDataDlg,
{ model: self, handleClose },
])
},
},
...(compatDisp.length > 1
? [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import React, { useEffect, useState } from 'react'
import {
Button,
DialogActions,
DialogContent,
FormControl,
FormControlLabel,
FormLabel,
Radio,
RadioGroup,
TextField,
Typography,
} from '@mui/material'
import { IAnyStateTreeNode } from 'mobx-state-tree'
import { makeStyles } from 'tss-react/mui'
import { saveAs } from 'file-saver'
import { observer } from 'mobx-react'
import { Dialog, ErrorMessage, LoadingEllipses } from '@jbrowse/core/ui'
import {
getSession,
getContainingView,
Feature,
Region,
AbstractTrackModel,
} from '@jbrowse/core/util'
import { getConf } from '@jbrowse/core/configuration'

// icons
import GetAppIcon from '@mui/icons-material/GetApp'

// locals
import { stringifyGFF3 } from './gff3'
import { stringifyGenbank } from './genbank'

const useStyles = makeStyles()({
root: {
width: '80em',
},
textAreaFont: {
fontFamily: 'Courier New',
},
})

async function fetchFeatures(
track: IAnyStateTreeNode,
regions: Region[],
signal?: AbortSignal,
) {
const { rpcManager } = getSession(track)
const adapterConfig = getConf(track, ['adapter'])
const sessionId = 'getFeatures'
return rpcManager.call(sessionId, 'CoreGetFeatures', {
adapterConfig,
regions,
sessionId,
signal,
}) as Promise<Feature[]>
}

export default observer(function SaveTrackDataDlg({
model,
handleClose,
}: {
model: AbstractTrackModel
handleClose: () => void
}) {
const { classes } = useStyles()
const [error, setError] = useState<unknown>()
const [features, setFeatures] = useState<Feature[]>()
const [type, setType] = useState('gff3')
const [str, setStr] = useState('')
const options = {
gff3: { name: 'GFF3', extension: 'gff3' },
genbank: { name: 'GenBank', extension: 'genbank' },
}

useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
;(async () => {
try {
const view = getContainingView(model) as { visibleRegions?: Region[] }
setError(undefined)
setFeatures(await fetchFeatures(model, view.visibleRegions || []))
} catch (e) {
console.error(e)
setError(e)
}
})()
}, [model])

useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
;(async () => {
try {
const view = getContainingView(model)
const session = getSession(model)
if (!features) {
return
}
const str = await (type === 'gff3'
? stringifyGFF3(features)
: stringifyGenbank({
features,
session,
assemblyName: view.dynamicBlocks.contentBlocks[0].assemblyName,
}))

setStr(str)
} catch (e) {
setError(e)
}
})()
}, [type, features, model])

return (
<Dialog maxWidth="xl" open onClose={handleClose} title="Save track data">
<DialogContent className={classes.root}>
{error ? <ErrorMessage error={error} /> : null}
{!features ? (
<LoadingEllipses />
) : !features.length ? (
<Typography>No features found</Typography>
) : null}

<FormControl>
<FormLabel>File type</FormLabel>
<RadioGroup value={type} onChange={e => setType(e.target.value)}>
{Object.entries(options).map(([key, val]) => (
<FormControlLabel
key={key}
value={key}
control={<Radio />}
label={val.name}
/>
))}
</RadioGroup>
</FormControl>
<TextField
variant="outlined"
multiline
minRows={5}
maxRows={15}
fullWidth
value={str}
InputProps={{
readOnly: true,
classes: {
input: classes.textAreaFont,
},
}}
/>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
const ext = options[type as keyof typeof options].extension
const blob = new Blob([str], { type: 'text/plain;charset=utf-8' })
saveAs(blob, `jbrowse_track_data.${ext}`)
}}
startIcon={<GetAppIcon />}
>
Download
</Button>

<Button variant="contained" type="submit" onClick={() => handleClose()}>
Close
</Button>
</DialogActions>
</Dialog>
)
})
Loading

0 comments on commit 1de51c9

Please sign in to comment.