diff --git a/packages/core/package.json b/packages/core/package.json index 89e75e54ab6..73785e80e9d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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": "^1.4.0", "is-object": "^1.0.1", diff --git a/packages/core/pluggableElementTypes/models/components/SaveTrackData.tsx b/packages/core/pluggableElementTypes/models/components/SaveTrackData.tsx index a2855d57cc4..cb1390a5f89 100644 --- a/packages/core/pluggableElementTypes/models/components/SaveTrackData.tsx +++ b/packages/core/pluggableElementTypes/models/components/SaveTrackData.tsx @@ -12,6 +12,7 @@ import { Typography, } from '@mui/material' 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 { @@ -23,8 +24,12 @@ import { import { getConf } from '@jbrowse/core/configuration' import { BaseTrackModel } from '@jbrowse/core/pluggableElementTypes' +// icons +import GetAppIcon from '@mui/icons-material/GetApp' + // locals -import { stringifyGenbank, stringifyGFF3 } from './util' +import { stringifyGFF3 } from './gff3' +import { stringifyGenbank } from './genbank' const useStyles = makeStyles()({ root: { @@ -62,7 +67,11 @@ export default observer(function SaveTrackDataDlg({ const [error, setError] = useState() const [features, setFeatures] = useState() const [type, setType] = useState('gff3') - const options = { gff3: 'GFF3', genbank: 'GenBank' } + 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 @@ -80,11 +89,25 @@ export default observer(function SaveTrackDataDlg({ })() }, [model]) - const str = features - ? type === 'gff3' - ? stringifyGFF3(features) - : stringifyGenbank(features, {}) - : '' + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + ;(async () => { + 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) + })() + }, [type, features, model]) return ( @@ -103,7 +126,12 @@ export default observer(function SaveTrackDataDlg({ onChange={event => setType(event.target.value)} > {Object.entries(options).map(([key, val]) => ( - } label={val} /> + } + label={val.name} + /> ))} @@ -123,6 +151,23 @@ export default observer(function SaveTrackDataDlg({ /> + + diff --git a/packages/core/pluggableElementTypes/models/components/genbank.ts b/packages/core/pluggableElementTypes/models/components/genbank.ts new file mode 100644 index 00000000000..92ca73ef759 --- /dev/null +++ b/packages/core/pluggableElementTypes/models/components/genbank.ts @@ -0,0 +1,186 @@ +import { + AbstractSessionModel, + Feature, + max, + min, + Region, +} from '@jbrowse/core/util' +import { getConf } from '@jbrowse/core/configuration' + +const coreFields = [ + 'uniqueId', + 'refName', + 'source', + 'type', + 'start', + 'end', + 'strand', + 'parent', + 'parentId', + 'score', + 'subfeatures', + 'phase', +] + +const blank = ' ' + +const retitle = { + name: 'Name', +} as { [key: string]: string | undefined } + +function fmt(obj: unknown): string { + if (Array.isArray(obj)) { + return obj.map(o => fmt(o)).join(',') + } else if (typeof obj === 'object') { + return JSON.stringify(obj) + } else { + return `${obj}` + } +} + +function formatTags(f: Feature, parentId?: string, parentType?: string) { + return [ + parentId && parentType ? `${blank}/${parentType}="${parentId}"` : '', + f.get('id') ? `${blank}/name=${f.get('id')}` : '', + ...f + .tags() + .filter(tag => !coreFields.includes(tag)) + .map(tag => [tag, fmt(f.get(tag))]) + .filter(tag => !!tag[1] && tag[0] !== parentType) + .map(tag => `${blank}/${retitle[tag[0]] || tag[0]}="${tag[1]}"`), + ].filter(f => !!f) +} + +function rs(f: Feature, min: number) { + return f.get('start') - min + 1 +} +function re(f: Feature, min: number) { + return f.get('end') - min +} +function loc(f: Feature, min: number) { + return `${rs(f, min)}..${re(f, min)}` +} +function formatFeat( + f: Feature, + min: number, + parentType?: string, + parentId?: string, +) { + const type = `${f.get('type')}`.slice(0, 16) + const l = loc(f, min) + const locstrand = f.get('strand') === -1 ? `complement(${l})` : l + return [ + ` ${type.padEnd(16)}${locstrand}`, + ...formatTags(f, parentType, parentId), + ] +} + +function formatCDS( + feats: Feature[], + parentId: string, + parentType: string, + strand: number, + min: number, +) { + const cds = feats.map(f => loc(f, min)) + const pre = `join(${cds})` + const str = strand === -1 ? `complement(${pre})` : pre + return feats.length + ? [` ${'CDS'.padEnd(16)}${str}`, `${blank}/${parentType}="${parentId}"`] + : [] +} + +export function formatFeatWithSubfeatures( + feature: Feature, + min: number, + parentId?: string, + parentType?: string, +): string { + const primary = formatFeat(feature, min, parentId, parentType) + const subfeatures = feature.get('subfeatures') || [] + const cds = subfeatures.filter(f => f.get('type') === 'CDS') + const sansCDS = subfeatures.filter( + f => f.get('type') !== 'CDS' && f.get('type') !== 'exon', + ) + const newParentId = feature.get('id') + const newParentType = feature.get('type') + const newParentStrand = feature.get('strand') + return [ + ...primary, + ...formatCDS(cds, newParentId, newParentType, newParentStrand, min), + ...sansCDS + .map(sub => + formatFeatWithSubfeatures(sub, min, newParentId, newParentType), + ) + .flat(), + ].join('\n') +} + +export async function stringifyGenbank({ + features, + assemblyName, + session, +}: { + assemblyName: string + session: AbstractSessionModel + features: Feature[] +}) { + const today = new Date() + const month = today.toLocaleString('en-US', { month: 'short' }).toUpperCase() + const day = today.toLocaleString('en-US', { day: 'numeric' }) + const year = today.toLocaleString('en-US', { year: 'numeric' }) + const date = `${day}-${month}-${year}` + + const start = min(features.map(f => f.get('start'))) + const end = max(features.map(f => f.get('end'))) + const length = end - start + const refName = features[0].get('refName') + + const l1 = [ + `${'LOCUS'.padEnd(12)}`, + `${refName}:${start + 1}..${end}`.padEnd(20), + ` ${`${length} bp`}`.padEnd(15), + ` ${'DNA'.padEnd(10)}`, + `${'linear'.padEnd(10)}`, + `${'UNK ' + date}`, + ].join('') + const l2 = 'FEATURES Location/Qualifiers' + const seq = await fetchSequence({ + session, + assemblyName, + regions: [{ assemblyName, start, end, refName }], + }) + const contig = seq.map(f => f.get('seq') || '').join('') + const lines = features.map(feat => formatFeatWithSubfeatures(feat, start)) + const seqlines = ['ORIGIN', `\t1 ${contig}`, '//'] + return [l1, l2, ...lines, ...seqlines].join('\n') +} + +async function fetchSequence({ + session, + regions, + signal, + assemblyName, +}: { + assemblyName: string + session: AbstractSessionModel + regions: Region[] + signal?: AbortSignal +}) { + const { rpcManager, assemblyManager } = session + const assembly = assemblyManager.get(assemblyName) + if (!assembly) { + throw new Error(`assembly ${assemblyName} not found`) + } + + const sessionId = 'getSequence' + return rpcManager.call(sessionId, 'CoreGetFeatures', { + adapterConfig: getConf(assembly, ['sequence', 'adapter']), + regions: regions.map(r => ({ + ...r, + refName: assembly.getCanonicalRefName(r.refName), + })), + sessionId, + signal, + }) as Promise +} diff --git a/packages/core/pluggableElementTypes/models/components/gff3.ts b/packages/core/pluggableElementTypes/models/components/gff3.ts new file mode 100644 index 00000000000..35531ce66d8 --- /dev/null +++ b/packages/core/pluggableElementTypes/models/components/gff3.ts @@ -0,0 +1,80 @@ +import { Feature } from '@jbrowse/core/util' + +const coreFields = [ + 'uniqueId', + 'refName', + 'source', + 'type', + 'start', + 'end', + 'strand', + 'parent', + 'parentId', + 'score', + 'subfeatures', + 'phase', +] + +const retitle = { + id: 'ID', + name: 'Name', + alias: 'Alias', + parent: 'Parent', + target: 'Target', + gap: 'Gap', + derives_from: 'Derives_from', + note: 'Note', + description: 'Note', + dbxref: 'Dbxref', + ontology_term: 'Ontology_term', + is_circular: 'Is_circular', +} as { [key: string]: string } + +function fmt(obj: unknown): string { + if (Array.isArray(obj)) { + return obj.map(o => fmt(o)).join(',') + } else if (typeof obj === 'object') { + return JSON.stringify(obj) + } else { + return `${obj}` + } +} + +function formatFeat(f: Feature, parentId?: string, parentRef?: string) { + return [ + f.get('refName') || parentRef, + f.get('source') || '.', + f.get('type') || '.', + f.get('start') + 1, + f.get('end'), + f.get('score') || '.', + f.get('strand') || '.', + f.get('phase') || '.', + (parentId ? `Parent=${parentId};` : '') + + f + .tags() + .filter(tag => !coreFields.includes(tag)) + .map(tag => [tag, fmt(f.get(tag))]) + .filter(tag => !!tag[1]) + .map(tag => `${retitle[tag[0]] || tag[0]}=${tag[1]}`) + .join(';'), + ].join('\t') +} +export function formatMultiLevelFeat( + f: Feature, + parentId?: string, + parentRef?: string, +): string { + const fRef = parentRef || f.get('refName') + const fId = f.get('id') + const primary = formatFeat(f, parentId, fRef) + const subs = + f.get('subfeatures')?.map(sub => formatMultiLevelFeat(sub, fId, fRef)) || [] + return [primary, ...subs].join('\n') +} + +export function stringifyGFF3(feats: Feature[]) { + return ['##gff-version 3', ...feats.map(f => formatMultiLevelFeat(f))].join( + '\n', + ) +} diff --git a/packages/core/pluggableElementTypes/models/components/util.ts b/packages/core/pluggableElementTypes/models/components/util.ts deleted file mode 100644 index b377301fcda..00000000000 --- a/packages/core/pluggableElementTypes/models/components/util.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Feature } from '@jbrowse/core/util' - -const coreFields = [ - 'uniqueId', - 'refName', - 'source', - 'type', - 'start', - 'end', - 'strand', - 'parent', - 'parentId', - 'score', - 'subfeatures', - 'phase', -] - -const retitle = { - id: 'ID', - name: 'Name', - alias: 'Alias', - parent: 'Parent', - target: 'Target', - gap: 'Gap', - derives_from: 'Derives_from', - note: 'Note', - description: 'Note', - dbxref: 'Dbxref', - ontology_term: 'Ontology_term', - is_circular: 'Is_circular', -} as { [key: string]: string } - -function fmt(obj: unknown): string { - if (Array.isArray(obj)) { - return obj.map(o => fmt(o)).join(',') - } else if (typeof obj === 'object') { - return JSON.stringify(obj) - } else { - return `${obj}` - } -} - -function formatFeat(f: Feature, parentId?: string) { - return [ - f.get('refName'), - f.get('source') || '.', - f.get('type') || '.', - f.get('start') + 1, - f.get('end'), - f.get('score') || '.', - f.get('strand') || '.', - f.get('phase') || '.', - (parentId ? `Parent=${parentId};` : '') + - f - .tags() - .filter(tag => !coreFields.includes(tag)) - .map(tag => [tag, fmt(f.get(tag))]) - .filter(tag => !!tag[1]) - .map(tag => `${retitle[tag[0]] || tag[0]}=${tag[1]}`) - .join(';'), - ].join('\t') -} -export function formatMultiLevelFeat(f: Feature, parentId?: string): string { - const primary = formatFeat(f, parentId) - const fId = f.get('id') - const subs = - f.get('subfeatures')?.map(sub => formatMultiLevelFeat(sub, fId)) || [] - return [primary, ...subs].join('\n') -} - -export function stringifyGFF3(feats: Feature[]) { - return ['##gff-version 3', ...feats.map(f => formatMultiLevelFeat(f))].join( - '\n', - ) -} -// LOCUS Exported 1699 bp DNA linear UNA 04-FEB-2023 -// FEATURES Location/Qualifiers -// source 1..1699 -// CDS join(428..659,794..1099) -// CDS complement(join(1278..1411,1515..1650)) -// ORIGIN -// 1 ttaatttgaaatagtttccattttttgataataatgaaaagctgctgaaaaaatggtttggcagttagcaattccaggaattttttcgagataagccataaattttaaaattatggaaattgatttacgtgtgtttttttctaattctaaattttttggtgacgttttccacgttgatttatttatttttcgaacccccctttccctcaaccaaaatagtatttattcttcagtttcaatattgtcaaaaagctcgatgcccgagtattttgaatcttctgcgatttcaattagaagaaatgctgcaggaaacgacgttcaaaaggtaattgaaagcatttagaacatctcataaagatgatgtttcagaacaaagttcaaaattggcttcacagtgtgatcgagcgtctcaagtggtggagtcccggacgatgtcagcagctcttcgtcgagaatgagctcatcgagctatgctacagagctcgtgagcagttctggaaaaacaaagtgaagctagatgtacgtttagcgtatgagggattagcaattcattttctaataatttcagatcgaagctcctgtcaaaatctgtggagacattcacggacagttcgaggacttgatggctctgttcgagttgaatgggtggcctgaagagcataagtaagccgccaatttgaatttggattagtatatgttttcatttcagatatctctttcttggtgattatgttgaccgtggtccattctccattgaagtcatcacactcctcttcacctttcaaatattgatgcctgacaaagtcttccttcttcgaggaaaccacgaaagccgccccgtcaatatgcaatatggattttatctggaatgcaagaagcgctactcagtcgccttgtatgatgcatttcaacttgcattcaattgtatgccactgtgcgctgtcgtgagcaagaagatcatatgtatgcatggaggaatatctgaagatctgattgacttgacgtaagatctttttccaatttccttatgtacttcaacaaccaatttccagacaactcgaaaagattgatcgtccatttgatattccggacattggcgtcatctccgacttgacctgggctgatcccgacgagaaggtcttcggatatgccgattctccacgtggcgcgggacgttctttcggtccgaatgcggtcaagaagttccttcaaatgcacaacctggatctagtcgttcgtgcccatcaggtcgtcatggatggttatgaattctttgcggaccgccaacttgtcacagtcttctcggcaccatcatactgcggacaattcgacaatgctgctgccgtgatgaatgttgacgacaaattgctctgtactttcacaatcttccgcccggatttgaaagttggcgacttcaagaagaaggacaagtgatattttgatttatcgaaataaagcattttttgtaccgtcttgattttcaggttaggctcgaatcacgcgcgcctgcttctcgaccttaaaaatgcctccaggtacaccaggaggcgagcccgctaagcaagaattccagcgccttctcccttctctcccgcttcctgagaatattgatgacataatcggtattctttttgtgtgtgcctgtatccattattcacgcacacaagaacaccaacaagcatgctggttttcttatata -/// / -export function stringifyGenbank( - feats: Feature[], - { name = 'Exported', length = 100 }: { name?: string; length?: number }, -) { - const today = new Date() - const month = today.toLocaleString('en-US', { month: 'short' }).toUpperCase() - const day = today.toLocaleString('en-US', { day: 'numeric' }) - const year = today.toLocaleString('en-US', { year: 'numeric' }) - const date = `${day}-${month}-${year}` - const l1 = `LOCUS ${name} ${length} bp DNA linear UNA ${date}` - const l2 = 'FEATURES Location/Qualifiers' - const l3 = ` source 1..${length}` - return [l1, l2, l3].join('\n') -} diff --git a/packages/core/util/index.ts b/packages/core/util/index.ts index 914601bbdf4..39001443ea8 100644 --- a/packages/core/util/index.ts +++ b/packages/core/util/index.ts @@ -5,10 +5,8 @@ import PluginManager from '../PluginManager' import { addDisposer, getParent, - getSnapshot, getEnv as getEnvMST, isAlive, - isStateTreeNode, hasParent, IAnyStateTreeNode, IStateTreeNode, @@ -24,7 +22,6 @@ import { isDisplayModel, isViewModel, isTrackModel, - AssemblyManager, Region, TypeTestedByPredicate, } from './types' @@ -41,6 +38,7 @@ export { SimpleFeature, isFeature } export type { Feature, SimpleFeatureSerialized } export * from './offscreenCanvasPonyfill' export * from './offscreenCanvasUtils' +export * from './renameRegions' export const inDevelopment = typeof process === 'object' && @@ -705,72 +703,6 @@ export function makeAbortableReaction( }) } -export function renameRegionIfNeeded( - refNameMap: Record, - region: Region, -): Region & { originalRefName?: string } { - if (isStateTreeNode(region) && !isAlive(region)) { - return region - } - - if (region && refNameMap && refNameMap[region.refName]) { - // clone the region so we don't modify it - if (isStateTreeNode(region)) { - // @ts-ignore - region = { ...getSnapshot(region) } - } else { - region = { ...region } - } - - // modify it directly in the container - const newRef = refNameMap[region.refName] - if (newRef) { - return { ...region, refName: newRef, originalRefName: region.refName } - } - } - return region -} - -export async function renameRegionsIfNeeded< - ARGTYPE extends { - assemblyName?: string - regions?: Region[] - signal?: AbortSignal - adapterConfig: unknown - sessionId: string - statusCallback?: (arg: string) => void - }, ->(assemblyManager: AssemblyManager, args: ARGTYPE) { - const { regions = [], adapterConfig } = args - if (!args.sessionId) { - throw new Error('sessionId is required') - } - - const assemblyNames = regions.map(region => region.assemblyName) - const assemblyMaps = Object.fromEntries( - await Promise.all( - assemblyNames.map(async assemblyName => { - return [ - assemblyName, - await assemblyManager.getRefNameMapForAdapter( - adapterConfig, - assemblyName, - args, - ), - ] - }), - ), - ) - - return { - ...args, - regions: regions.map((region, i) => - // note: uses assemblyNames defined above since region could be dead now - renameRegionIfNeeded(assemblyMaps[assemblyNames[i]], region), - ), - } -} - export function minmax(a: number, b: number) { return [Math.min(a, b), Math.max(a, b)] } diff --git a/packages/core/util/renameRegions.ts b/packages/core/util/renameRegions.ts new file mode 100644 index 00000000000..ebe70e52688 --- /dev/null +++ b/packages/core/util/renameRegions.ts @@ -0,0 +1,68 @@ +import { getSnapshot, isAlive, isStateTreeNode } from 'mobx-state-tree' +import { AssemblyManager, Region } from './types' + +export async function renameRegionsIfNeeded< + ARGTYPE extends { + assemblyName?: string + regions?: Region[] + signal?: AbortSignal + adapterConfig: unknown + sessionId: string + statusCallback?: (arg: string) => void + }, +>(assemblyManager: AssemblyManager, args: ARGTYPE) { + const { regions = [], adapterConfig } = args + if (!args.sessionId) { + throw new Error('sessionId is required') + } + + const assemblyNames = regions.map(region => region.assemblyName) + const assemblyMaps = Object.fromEntries( + await Promise.all( + assemblyNames.map(async assemblyName => { + return [ + assemblyName, + await assemblyManager.getRefNameMapForAdapter( + adapterConfig, + assemblyName, + args, + ), + ] + }), + ), + ) + + return { + ...args, + regions: regions.map((region, i) => + // note: uses assemblyNames defined above since region could be dead now + renameRegionIfNeeded(assemblyMaps[assemblyNames[i]], region), + ), + } +} + +export function renameRegionIfNeeded( + refNameMap: Record, + region: Region, +): Region & { originalRefName?: string } { + if (isStateTreeNode(region) && !isAlive(region)) { + return region + } + + if (region && refNameMap && refNameMap[region.refName]) { + // clone the region so we don't modify it + if (isStateTreeNode(region)) { + // @ts-ignore + region = { ...getSnapshot(region) } + } else { + region = { ...region } + } + + // modify it directly in the container + const newRef = refNameMap[region.refName] + if (newRef) { + return { ...region, refName: newRef, originalRefName: region.refName } + } + } + return region +} diff --git a/plugins/alignments/src/LinearReadCloudDisplay/declare.d.ts b/plugins/alignments/src/LinearReadCloudDisplay/declare.d.ts new file mode 100644 index 00000000000..6ba8b2994b2 --- /dev/null +++ b/plugins/alignments/src/LinearReadCloudDisplay/declare.d.ts @@ -0,0 +1 @@ +declare module 'canvas2svg' diff --git a/plugins/gff3/src/Gff3TabixAdapter/Gff3TabixAdapter.ts b/plugins/gff3/src/Gff3TabixAdapter/Gff3TabixAdapter.ts index c7b4358685f..1210de15c15 100644 --- a/plugins/gff3/src/Gff3TabixAdapter/Gff3TabixAdapter.ts +++ b/plugins/gff3/src/Gff3TabixAdapter/Gff3TabixAdapter.ts @@ -148,7 +148,8 @@ export default class extends BaseFeatureDataAdapter { f.get('end'), originalQuery.start, originalQuery.end, - ) + ) && + f.get('type') !== 'region' ) { observer.next(f) } diff --git a/plugins/linear-genome-view/src/LinearBasicDisplay/model.ts b/plugins/linear-genome-view/src/LinearBasicDisplay/model.ts index 505c576ec2b..fc91a7df3bd 100644 --- a/plugins/linear-genome-view/src/LinearBasicDisplay/model.ts +++ b/plugins/linear-genome-view/src/LinearBasicDisplay/model.ts @@ -13,10 +13,8 @@ import VisibilityIcon from '@mui/icons-material/Visibility' // locals import { BaseLinearDisplay } from '../BaseLinearDisplay' -import { Save } from '@jbrowse/core/ui/Icons' const SetMaxHeightDlg = lazy(() => import('./components/SetMaxHeight')) -const SaveTrackDataDlg = lazy(() => import('./components/SaveTrackData')) /** * #stateModel LinearBasicDisplay @@ -175,17 +173,6 @@ function stateModelFactory(configSchema: AnyConfigurationSchemaType) { checked: self.showLabels, onClick: () => self.toggleShowLabels(), }, - - { - label: 'Save track data', - icon: Save, - onClick: () => { - getSession(self).queueDialog(handleClose => [ - SaveTrackDataDlg, - { model: self, handleClose }, - ]) - }, - }, { label: 'Show descriptions', icon: VisibilityIcon,