diff --git a/packages/core/package.json b/packages/core/package.json index cb3d73b7fb..975c7a1238 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,6 +45,7 @@ "dompurify": "^3.2.0", "escape-html": "^1.0.3", "fast-deep-equal": "^3.1.3", + "file-saver": "^2.0.0", "generic-filehandle2": "^1.0.0", "jexl": "^2.3.0", "librpc-web-mod": "^1.1.5", diff --git a/packages/core/pluggableElementTypes/models/BaseTrackModel.ts b/packages/core/pluggableElementTypes/models/BaseTrackModel.ts index 67b7913fb1..272077e7c0 100644 --- a/packages/core/pluggableElementTypes/models/BaseTrackModel.ts +++ b/packages/core/pluggableElementTypes/models/BaseTrackModel.ts @@ -1,9 +1,16 @@ +import { lazy } from 'react' + import { transaction } from 'mobx' import { getRoot, resolveIdentifier, types } from 'mobx-state-tree' +import { Save } from '@mui/icons-material' + import { ConfigurationReference, getConf } from '../../configuration' import { adapterConfigCacheKey } from '../../data_adapters/util' import { getContainingView, getEnv, getSession } from '../../util' +import { stringifyBED } from './saveTrackFileTypes/bed' +import { stringifyGBK } from './saveTrackFileTypes/genbank' +import { stringifyGFF3 } from './saveTrackFileTypes/gff3' import { isSessionModelWithConfigEditing } from '../../util/types' import { ElementId } from '../../util/types/mst' @@ -15,6 +22,9 @@ import type { import type { MenuItem } from '../../ui' import type { IAnyStateTreeNode, Instance } from 'mobx-state-tree' +// lazies +const SaveTrackDataDlg = lazy(() => import('./components/SaveTrackData')) + export function getCompatibleDisplays(self: IAnyStateTreeNode) { const { pluginManager } = getEnv(self) const view = getContainingView(self) @@ -181,6 +191,27 @@ export function createBaseTrackModel( }) }, })) + .views(() => ({ + saveTrackFileFormatOptions() { + return { + gff3: { + name: 'GFF3', + extension: 'gff3', + callback: stringifyGFF3, + }, + genbank: { + name: 'GenBank', + extension: 'gbk', + callback: stringifyGBK, + }, + bed: { + name: 'BED', + extension: 'bed', + callback: stringifyBED, + }, + } + }, + })) .views(self => ({ /** * #method @@ -194,6 +225,19 @@ export function createBaseTrackModel( return [ ...menuItems, + { + label: 'Save track data', + icon: Save, + onClick: () => { + getSession(self).queueDialog(handleClose => [ + SaveTrackDataDlg, + { + model: self, + handleClose, + }, + ]) + }, + }, ...(compatDisp.length > 1 ? [ { diff --git a/packages/core/pluggableElementTypes/models/components/SaveTrackData.tsx b/packages/core/pluggableElementTypes/models/components/SaveTrackData.tsx new file mode 100644 index 0000000000..298e6a1983 --- /dev/null +++ b/packages/core/pluggableElementTypes/models/components/SaveTrackData.tsx @@ -0,0 +1,200 @@ +import React, { useEffect, useState } from 'react' + +import { getConf } from '@jbrowse/core/configuration' +import { Dialog, ErrorMessage } from '@jbrowse/core/ui' +import { getContainingView, getSession } from '@jbrowse/core/util' +import GetAppIcon from '@mui/icons-material/GetApp' +import { + Button, + DialogActions, + DialogContent, + FormControl, + FormControlLabel, + FormLabel, + Radio, + RadioGroup, + TextField, + Typography, +} from '@mui/material' +import { saveAs } from 'file-saver' +import { observer } from 'mobx-react' +import { makeStyles } from 'tss-react/mui' + +import type { + AbstractSessionModel, + AbstractTrackModel, + Feature, + Region, +} from '@jbrowse/core/util' +import type { IAnyStateTreeNode } from 'mobx-state-tree' + +// icons + +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 +} +interface FileTypeExporter { + name: string + extension: string + callback: (arg: { + features: Feature[] + session: AbstractSessionModel + assemblyName: string + }) => Promise | string +} +const SaveTrackDataDialog = observer(function ({ + model, + handleClose, +}: { + model: AbstractTrackModel & { + saveTrackFileFormatOptions: () => Record + } + handleClose: () => void +}) { + const options = model.saveTrackFileFormatOptions() + const { classes } = useStyles() + const [error, setError] = useState() + const [features, setFeatures] = useState() + const [type, setType] = useState(Object.keys(options)[0]) + const [str, setStr] = useState('') + + 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 { visibleRegions } = getContainingView(model) as { + visibleRegions?: Region[] + } + const session = getSession(model) + if (!features || !visibleRegions?.length || !type) { + return + } + const generator = options[type] || { + callback: () => 'Unknown', + } + setStr( + await generator.callback({ + features, + session, + assemblyName: visibleRegions[0]!.assemblyName, + }), + ) + } catch (e) { + setError(e) + } + })() + }, [type, features, options, model]) + + const loading = !features + return ( + + + {error ? : null} + {features && !features.length ? ( + No features found + ) : null} + + + File type + { + setType(e.target.value) + }} + > + {Object.entries(options).map(([key, val]) => ( + } + label={val.name} + /> + ))} + + + 100_000 + ? 'Too large to view here, click "Download" to results to file' + : str + } + InputProps={{ + readOnly: true, + classes: { + input: classes.textAreaFont, + }, + }} + /> + + + + + + + + ) +}) + +export default SaveTrackDataDialog diff --git a/packages/core/pluggableElementTypes/models/saveTrackFileTypes/bed.ts b/packages/core/pluggableElementTypes/models/saveTrackFileTypes/bed.ts new file mode 100644 index 0000000000..ca0a2b6ffb --- /dev/null +++ b/packages/core/pluggableElementTypes/models/saveTrackFileTypes/bed.ts @@ -0,0 +1,13 @@ +import type { Feature } from '@jbrowse/core/util' + +export function stringifyBED({ features }: { features: Feature[] }) { + const fields = ['refName', 'start', 'end', 'name', 'score', 'strand'] + return features + .map(feature => + fields + .map(field => feature.get(field)) + .join('\t') + .trim(), + ) + .join('\n') +} diff --git a/packages/core/pluggableElementTypes/models/saveTrackFileTypes/genbank.ts b/packages/core/pluggableElementTypes/models/saveTrackFileTypes/genbank.ts new file mode 100644 index 0000000000..57eef2d073 --- /dev/null +++ b/packages/core/pluggableElementTypes/models/saveTrackFileTypes/genbank.ts @@ -0,0 +1,182 @@ +import { getConf } from '@jbrowse/core/configuration' +import { max, min } from '@jbrowse/core/util' + +import type { AbstractSessionModel, Feature, Region } from '@jbrowse/core/util' + +const coreFields = new Set([ + 'uniqueId', + 'refName', + 'source', + 'type', + 'start', + 'end', + 'strand', + 'parent', + 'parentId', + 'score', + 'subfeatures', + 'phase', +]) + +const blank = ' ' + +const retitle = { + name: 'Name', +} as Record + +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')}` : '', + ...Object.keys(f.toJSON()) + .filter(tag => !coreFields.has(tag)) + .map(tag => [tag, fmt(f.get(tag))] as const) + .filter(tag => !!tag[1] && tag[0] !== parentType) + .map(tag => `${blank}/${retitle[tag[0]] || tag[0]}="${tag[1]}"`), + ].filter(f => !!f) +} + +function relativeStart(f: Feature, min: number) { + return f.get('start') - min + 1 +} +function relativeEnd(f: Feature, min: number) { + return f.get('end') - min +} +function loc(f: Feature, min: number) { + return `${relativeStart(f, min)}..${relativeEnd(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.flatMap(sub => + formatFeatWithSubfeatures(sub, min, newParentId, newParentType), + ), + ].join('\n') +} + +export async function stringifyGBK({ + features, + assemblyName, + session, +}: { + assemblyName: string + session: AbstractSessionModel + features: Feature[] +}) { + if (!features.length) { + return '' + } + 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/saveTrackFileTypes/gff3.ts b/packages/core/pluggableElementTypes/models/saveTrackFileTypes/gff3.ts new file mode 100644 index 0000000000..f953fa41ae --- /dev/null +++ b/packages/core/pluggableElementTypes/models/saveTrackFileTypes/gff3.ts @@ -0,0 +1,84 @@ +import type { Feature } from '@jbrowse/core/util' + +const coreFields = new Set([ + '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 Record + +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') === 1 ? '+' : f.get('strand') === -1 ? '-' : '.', + f.get('phase') || '.', + (parentId ? `Parent=${parentId};` : '') + + Object.keys(f.toJSON()) + .filter(tag => !coreFields.has(tag)) + .map(tag => [tag, fmt(f.get(tag))] as const) + .filter(tag => !!tag[1]) + .map(tag => `${retitle[tag[0]] || tag[0]}=${tag[1]}`) + .join(';'), + ].join('\t') +} +export function formatMultiLevelFeat( + feature: Feature, + parentId?: string, + parentRef?: string, +): string { + const featureRefName = parentRef || feature.get('refName') + const featureId = feature.get('id') + const primary = formatFeat(feature, parentId, featureRefName) + + return [ + primary, + ...(feature + .get('subfeatures') + ?.map(sub => formatMultiLevelFeat(sub, featureId, featureRefName)) || []), + ].join('\n') +} + +export function stringifyGFF3({ features }: { features: Feature[] }) { + return [ + '##gff-version 3', + ...features.map(f => formatMultiLevelFeat(f)), + ].join('\n') +} diff --git a/packages/core/rpc/coreRpcMethods.ts b/packages/core/rpc/coreRpcMethods.ts index e81beffae9..64f8296d3e 100644 --- a/packages/core/rpc/coreRpcMethods.ts +++ b/packages/core/rpc/coreRpcMethods.ts @@ -6,4 +6,5 @@ export { default as CoreGetFeatures } from './methods/CoreGetFeatures' export { default as CoreRender } from './methods/CoreRender' export { default as CoreFreeResources } from './methods/CoreFreeResources' export { default as CoreGetFeatureDensityStats } from './methods/CoreGetFeatureDensityStats' +export { default as CoreGetRegions } from './methods/CoreGetRegions' export { type RenderArgs } from './methods/util' diff --git a/packages/core/rpc/methods/CoreGetRefNames.ts b/packages/core/rpc/methods/CoreGetRefNames.ts index 6884b745cf..120d41630e 100644 --- a/packages/core/rpc/methods/CoreGetRefNames.ts +++ b/packages/core/rpc/methods/CoreGetRefNames.ts @@ -17,10 +17,8 @@ export default class CoreGetRefNames extends RpcMethodType { const deserializedArgs = await this.deserializeArguments(args, rpcDriver) const { sessionId, adapterConfig } = deserializedArgs const { dataAdapter } = await getAdapter(pm, sessionId, adapterConfig) - - if (isFeatureAdapter(dataAdapter)) { - return dataAdapter.getRefNames(deserializedArgs) - } - return [] + return isFeatureAdapter(dataAdapter) + ? dataAdapter.getRefNames(deserializedArgs) + : [] } } diff --git a/packages/core/rpc/methods/CoreGetRegions.ts b/packages/core/rpc/methods/CoreGetRegions.ts new file mode 100644 index 0000000000..ce45bb5187 --- /dev/null +++ b/packages/core/rpc/methods/CoreGetRegions.ts @@ -0,0 +1,23 @@ +import { isRegionsAdapter } from '../../data_adapters/BaseAdapter' +import { getAdapter } from '../../data_adapters/dataAdapterCache' +import RpcMethodType from '../../pluggableElementTypes/RpcMethodType' + +export default class CoreGetRegions extends RpcMethodType { + name = 'CoreGetRegions' + + async execute( + args: { + sessionId: string + adapterConfig: Record + }, + rpcDriver: string, + ) { + const pm = this.pluginManager + const deserializedArgs = await this.deserializeArguments(args, rpcDriver) + const { sessionId, adapterConfig } = deserializedArgs + const { dataAdapter } = await getAdapter(pm, sessionId, adapterConfig) + return isRegionsAdapter(dataAdapter) + ? dataAdapter.getRegions(deserializedArgs) + : [] + } +} diff --git a/packages/core/rpc/methods/CoreSaveFeatureData.ts b/packages/core/rpc/methods/CoreSaveFeatureData.ts new file mode 100644 index 0000000000..a15a388c9e --- /dev/null +++ b/packages/core/rpc/methods/CoreSaveFeatureData.ts @@ -0,0 +1,61 @@ +import { firstValueFrom } from 'rxjs' +import { toArray } from 'rxjs/operators' + +// locals +import { isFeatureAdapter } from '../../data_adapters/BaseAdapter' +import { getAdapter } from '../../data_adapters/dataAdapterCache' +import RpcMethodType from '../../pluggableElementTypes/RpcMethodType' +import { renameRegionsIfNeeded } from '../../util' +import SimpleFeature from '../../util/simpleFeature' + +import type { RenderArgs } from './util' +import type { Region } from '../../util' +import type { SimpleFeatureSerialized } from '../../util/simpleFeature' + +export default class CoreGetFeatures extends RpcMethodType { + name = 'CoreGetFeatures' + + async deserializeReturn( + feats: SimpleFeatureSerialized[], + args: unknown, + rpcDriver: string, + ) { + const superDeserialized = (await super.deserializeReturn( + feats, + args, + rpcDriver, + )) as SimpleFeatureSerialized[] + return superDeserialized.map(feat => new SimpleFeature(feat)) + } + + async serializeArguments(args: RenderArgs, rpcDriver: string) { + const { rootModel } = this.pluginManager + const assemblyManager = rootModel!.session!.assemblyManager + const renamedArgs = await renameRegionsIfNeeded(assemblyManager, args) + return super.serializeArguments( + renamedArgs, + rpcDriver, + ) as Promise + } + + async execute( + args: { + sessionId: string + regions: Region[] + adapterConfig: Record + opts?: any + }, + rpcDriver: string, + ) { + const pm = this.pluginManager + const deserializedArgs = await this.deserializeArguments(args, rpcDriver) + const { sessionId, adapterConfig, regions, opts } = deserializedArgs + const { dataAdapter } = await getAdapter(pm, sessionId, adapterConfig) + if (!isFeatureAdapter(dataAdapter)) { + throw new Error('Adapter does not support retrieving features') + } + const ret = dataAdapter.getFeaturesInMultipleRegions(regions, opts) + const r = await firstValueFrom(ret.pipe(toArray())) + return r.map(f => f.toJSON()) + } +} diff --git a/packages/core/ui/Dialog.tsx b/packages/core/ui/Dialog.tsx index 1db191a736..740a91dfc5 100644 --- a/packages/core/ui/Dialog.tsx +++ b/packages/core/ui/Dialog.tsx @@ -37,7 +37,7 @@ function DialogError({ error }: { error: unknown }) { ) } -interface Props extends DialogProps { +export interface Props extends DialogProps { header?: React.ReactNode } diff --git a/packages/core/ui/Menu.tsx b/packages/core/ui/Menu.tsx index bb90d16b7e..7e3ff0336f 100644 --- a/packages/core/ui/Menu.tsx +++ b/packages/core/ui/Menu.tsx @@ -415,7 +415,7 @@ const MenuPage = forwardRef( }, ) -interface MenuProps extends PopoverProps { +export interface MenuProps extends PopoverProps { menuItems: MenuItem[] onMenuItemClick: ( event: React.MouseEvent, diff --git a/plugins/linear-genome-view/src/LinearGenomeView/components/GetSequenceDialog.tsx b/plugins/linear-genome-view/src/LinearGenomeView/components/GetSequenceDialog.tsx index dc5ba9a5fb..b83f05435a 100644 --- a/plugins/linear-genome-view/src/LinearGenomeView/components/GetSequenceDialog.tsx +++ b/plugins/linear-genome-view/src/LinearGenomeView/components/GetSequenceDialog.tsx @@ -165,7 +165,6 @@ const GetSequenceDialog = observer(function ({ ) : null} ({ }, }, trackName: { + margin: '0 auto', + width: '90%', fontSize: '0.8rem', + pointerEvents: 'none', }, iconButton: { padding: theme.spacing(1), @@ -72,10 +75,6 @@ const TrackLabel = observer( variant="body1" component="span" className={classes.trackName} - onMouseDown={event => { - // avoid becoming a click-and-drag action on the lgv - event.stopPropagation() - }} > Foo Track @@ -1331,7 +1331,7 @@ exports[`renders two tracks, two regions 1`] = ` Foo Track @@ -1456,7 +1456,7 @@ exports[`renders two tracks, two regions 1`] = ` Bar Track diff --git a/plugins/linear-genome-view/src/LinearGenomeView/model.ts b/plugins/linear-genome-view/src/LinearGenomeView/model.ts index 32712888b8..7f005a8159 100644 --- a/plugins/linear-genome-view/src/LinearGenomeView/model.ts +++ b/plugins/linear-genome-view/src/LinearGenomeView/model.ts @@ -1694,6 +1694,10 @@ export function stateModelFactory(pluginManager: PluginManager) { ? this.pxToBp(self.width / 2) : undefined }, + + get visibleRegions() { + return self.dynamicBlocks.contentBlocks + }, })) .actions(self => ({ afterCreate() { diff --git a/plugins/variants/src/VariantTrack/index.ts b/plugins/variants/src/VariantTrack/index.ts index 55dcfffc0d..8ac2516dec 100644 --- a/plugins/variants/src/VariantTrack/index.ts +++ b/plugins/variants/src/VariantTrack/index.ts @@ -2,6 +2,7 @@ import TrackType from '@jbrowse/core/pluggableElementTypes/TrackType' import { createBaseTrackModel } from '@jbrowse/core/pluggableElementTypes/models' import configSchemaF from './configSchema' +import { stringifyVCF } from './saveTrackFormats/vcf' import type PluginManager from '@jbrowse/core/PluginManager' @@ -12,7 +13,19 @@ export default function VariantTrackF(pm: PluginManager) { name: 'VariantTrack', displayName: 'Variant track', configSchema, - stateModel: createBaseTrackModel(pm, 'VariantTrack', configSchema), + stateModel: createBaseTrackModel(pm, 'VariantTrack', configSchema).views( + () => ({ + saveTrackFileFormatOptions() { + return { + vcf: { + name: 'VCF', + extension: 'vcf', + callback: stringifyVCF, + }, + } + }, + }), + ), }) }) } diff --git a/plugins/variants/src/VariantTrack/saveTrackFormats/vcf.ts b/plugins/variants/src/VariantTrack/saveTrackFormats/vcf.ts new file mode 100644 index 0000000000..aeb7dbd39e --- /dev/null +++ b/plugins/variants/src/VariantTrack/saveTrackFormats/vcf.ts @@ -0,0 +1,17 @@ +import type { Feature } from '@jbrowse/core/util' + +function generateINFO(feature: Feature) { + return Object.entries(feature.get('INFO')) + .map(([key, value]) => `${key}=${value}`) + .join(';') +} +export function stringifyVCF({ features }: { features: Feature[] }) { + const fields = ['refName', 'start', 'name', 'REF', 'ALT', 'QUAL', 'FILTER'] + return features + .map(feature => { + return `${fields + .map(field => feature.get(field) || '.') + .join('\t')}\t${generateINFO(feature)}` + }) + .join('\n') +} diff --git a/products/jbrowse-react-linear-genome-view/src/JBrowseLinearGenomeView/__snapshots__/JBrowseLinearGenomeView.test.tsx.snap b/products/jbrowse-react-linear-genome-view/src/JBrowseLinearGenomeView/__snapshots__/JBrowseLinearGenomeView.test.tsx.snap index 10b4ff98c1..c1942b4e7d 100644 --- a/products/jbrowse-react-linear-genome-view/src/JBrowseLinearGenomeView/__snapshots__/JBrowseLinearGenomeView.test.tsx.snap +++ b/products/jbrowse-react-linear-genome-view/src/JBrowseLinearGenomeView/__snapshots__/JBrowseLinearGenomeView.test.tsx.snap @@ -983,7 +983,7 @@ exports[` renders successfully 1`] = ` Reference sequence (volvox)