From a29460e0c3b1ff1f61bffef3cc7b776d5eb009f0 Mon Sep 17 00:00:00 2001 From: Andy Boughton Date: Tue, 2 Nov 2021 21:00:50 -0400 Subject: [PATCH 1/8] Use constants for datatypes --- src/components/ExportData.vue | 3 ++- src/components/TabixAdder.vue | 16 ++++++++++------ src/util/constants.js | 8 ++++++++ src/util/lz-helpers.js | 14 +++++++------- 4 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/components/ExportData.vue b/src/components/ExportData.vue index eb1be04..f721c80 100644 --- a/src/components/ExportData.vue +++ b/src/components/ExportData.vue @@ -8,6 +8,7 @@ import LocusZoom from 'locuszoom'; import { sourceName } from '../util/lz-helpers'; import TabulatorTable from './TabulatorTable.vue'; +import { DATA_TYPES } from '../util/constants'; function formatSciNotation(cell, params) { // Tabulator cell formatter using sci notation @@ -39,7 +40,7 @@ export default { }, gwas_tracks() { - return this.known_tracks.filter(({data_type}) => data_type === 'gwas'); + return this.known_tracks.filter(({data_type}) => data_type === DATA_TYPES.GWAS); }, table_config() { diff --git a/src/components/TabixAdder.vue b/src/components/TabixAdder.vue index 4f8119b..f15e978 100644 --- a/src/components/TabixAdder.vue +++ b/src/components/TabixAdder.vue @@ -7,6 +7,7 @@ import { makeBed12Parser, makeGWASParser, makePlinkLdParser } from 'locuszoom/es import { blobReader, urlReader } from 'tabix-reader'; import GwasParserOptions from './GwasParserOptions.vue'; +import { DATA_TYPES } from '../util/constants'; import { positionToStartRange } from '../util/entity-helpers'; @@ -21,7 +22,7 @@ export default { return { tabix_mode: 'file', display_name: '', - data_type: 'gwas', + data_type: DATA_TYPES.GWAS, tabix_gz_url: '', // Options required to pass props to the GWAS modal filename: '', @@ -29,6 +30,9 @@ export default { tabix_reader: null, }; }, + beforeCreate() { + this.DATA_TYPES = DATA_TYPES; + }, methods: { reset() { // Note: Deliberately don't reset datatype, for workflows of "add more of the same" @@ -105,7 +109,7 @@ export default { suggestRegion(data_type, reader, parser) { // Note; the GWAS modal does this for GWAS data internally. return new Promise((resolve, reject) => { - if (data_type === 'bed') { + if (data_type === DATA_TYPES.BED) { // 1. Find headers (has tabs, does not begin with "browser", "track", or comment mark) // 2. Get first data row and return the coordinates for that row. This method assumes the LZ parser with field names according to UCSC BED spec const header_prefixes = /^(browser | track |#)/; @@ -165,7 +169,7 @@ export default { } reader_promise.then(([reader, filename]) => { - if (data_type === 'gwas') { + if (data_type === DATA_TYPES.GWAS) { // GWAS files are very messy, and so knowing where to find the file is not enough. // After receiving the reader, we need to ask the user how to parse the file. (via UI) if (filename.includes('.bed')) { @@ -180,12 +184,12 @@ export default { // All other data types are quasi-standardized, and hence we can declare this // new track immediately after verifying that a valid reader exists let parser; - if (data_type === 'bed') { + if (data_type === DATA_TYPES.BED) { parser = makeBed12Parser({normalize: true}); if (!filename.includes('.bed')) { throw new Error('BED interval file names must include extension .bed'); } - } else if (data_type === 'plink_ld') { + } else if (data_type === DATA_TYPES.PLINK_LD) { parser = makePlinkLdParser({normalize: true}); } else { throw new Error('Unrecognized datatype'); @@ -208,7 +212,7 @@ export default { const { tabix_reader, filename, display_name } = this; // Receive GWAS options and declare a new track for this datatype const parser = makeGWASParser(parser_config); - this.sendTrackOptions('gwas', tabix_reader, parser, filename, display_name, region_config); + this.sendTrackOptions(DATA_TYPES.GWAS, tabix_reader, parser, filename, display_name, region_config); this.reset(); }, diff --git a/src/util/constants.js b/src/util/constants.js index edb6268..4bd1674 100644 --- a/src/util/constants.js +++ b/src/util/constants.js @@ -6,7 +6,15 @@ const REGEX_REGION = /^(?:chr)?(\w+)\s*:\s*(\d+)-(\d+)$/; const DEFAULT_REGION_SIZE = 500000; +// Enum that controls recognized data types, so we can rename/ adjust as needed. +const DATA_TYPES = Object.freeze({ + BED: 'bed', + GWAS: 'gwas', + PLINK_LD: 'plink_ld', +}); + export { + DATA_TYPES, DEFAULT_REGION_SIZE, REGEX_REGION, REGEX_POSITION, PORTAL_API_BASE_URL, LD_SERVER_BASE_URL, diff --git a/src/util/lz-helpers.js b/src/util/lz-helpers.js index 8729568..c5d88e2 100644 --- a/src/util/lz-helpers.js +++ b/src/util/lz-helpers.js @@ -4,7 +4,7 @@ import tabixSource from 'locuszoom/esm/ext/lz-tabix-source'; import intervalTracks from 'locuszoom/esm/ext/lz-intervals-track'; import lzParsers from 'locuszoom/esm/ext/lz-parsers'; -import { PORTAL_API_BASE_URL, LD_SERVER_BASE_URL } from './constants'; +import { PORTAL_API_BASE_URL, LD_SERVER_BASE_URL, DATA_TYPES } from './constants'; LocusZoom.use(credibleSets); LocusZoom.use(tabixSource); @@ -145,9 +145,9 @@ function createGwasStudyLayout( function createStudyLayouts (data_type, filename, display_name, annotations) { const track_id = `${data_type}_${sourceName(filename)}`; - if (data_type === 'gwas') { + if (data_type === DATA_TYPES.GWAS) { return createGwasStudyLayout(track_id, display_name, annotations); - } else if (data_type === 'bed') { + } else if (data_type === DATA_TYPES.BED) { return [ LocusZoom.Layouts.get('panel', 'bed_intervals', { id: track_id, @@ -155,7 +155,7 @@ function createStudyLayouts (data_type, filename, display_name, annotations) { title: { text: display_name }, }), ]; - } else if (data_type === 'plink_ld') { + } else if (data_type === DATA_TYPES.PLINK_LD) { throw new Error('Not yet implemented'); } else { throw new Error('Unrecognized datatype'); @@ -187,13 +187,13 @@ function createGwasTabixSources(track_id, tabix_reader, parser_func) { function createStudySources(data_type, tabix_reader, filename, parser_func) { // todo rename to GET from CREATE, for consistency const track_id = `${data_type}_${sourceName(filename)}`; - if (data_type === 'gwas') { + if (data_type === DATA_TYPES.GWAS) { return createGwasTabixSources(track_id, tabix_reader, parser_func); - } else if (data_type === 'bed') { + } else if (data_type === DATA_TYPES.BED) { return [ [track_id, ['TabixUrlSource', {reader: tabix_reader, parser_func }]], ]; - } else if (data_type === 'plink_ld') { + } else if (data_type === DATA_TYPES.PLINK_LD) { throw new Error('Not yet implemented'); } else { throw new Error('Unrecognized datatype'); From 0c3fd9f977ba12940179cbd321fcda2917930e10 Mon Sep 17 00:00:00 2001 From: Andy Boughton Date: Tue, 2 Nov 2021 23:17:05 -0400 Subject: [PATCH 2/8] Implement "add user LD" feature --- package-lock.json | 4 +-- package.json | 2 +- src/App.vue | 10 +++++- src/components/GwasToolbar.vue | 2 +- src/components/TabixAdder.vue | 13 ++++---- src/util/lz-helpers.js | 61 ++++++++++++++++++++++++++++++++-- 6 files changed, 78 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 36b7781..f062695 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7843,8 +7843,8 @@ } }, "locuszoom": { - "version": "git+https://github.com/statgen/locuszoom.git#780cfd836f2e8f07333a0235ce65fe87fd899630", - "from": "git+https://github.com/statgen/locuszoom.git#780cfd8", + "version": "git+https://github.com/statgen/locuszoom.git#ce641a555f8c7dbebbaa4375bf146ce83c59646f", + "from": "git+https://github.com/statgen/locuszoom.git#ce641a5", "requires": { "d3": "^5.16.0", "gwas-credible-sets": "^0.1.0", diff --git a/package.json b/package.json index d36a2e1..6a69d3e 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@sentry/browser": "^4.5.2", "bootstrap": "^4.4.1", "bootstrap-vue": "^2.21.2", - "locuszoom": "git+https://github.com/statgen/locuszoom.git#780cfd8", + "locuszoom": "git+https://github.com/statgen/locuszoom.git#ce641a5", "lodash": "^4.17.11", "tabulator-tables": "^4.9.0", "vue": "^2.6.14", diff --git a/src/App.vue b/src/App.vue index 912c020..0895259 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,12 +4,13 @@ import 'locuszoom/dist/locuszoom.css'; import { BCard, BCollapse, VBToggle } from 'bootstrap-vue/src/'; import { - getBasicSources, getBasicLayout, + activateUserLD, getBasicSources, getBasicLayout, } from './util/lz-helpers'; import { count_add_track, count_region_view, setup_feature_metrics } from './util/metrics'; import PlotPanes from './components/PlotPanes.vue'; import GwasToolbar from './components/GwasToolbar.vue'; +import { DATA_TYPES } from './util/constants'; export default { @@ -53,6 +54,13 @@ export default { } else { // TODO: We presently ignore extra plot state (like region) when adding new tracks. Revisit for future data types. this.$refs.plotWidget.addStudy(panel_configs, source_configs); + if (data_type === DATA_TYPES.PLINK_LD) { + // Modify plot widget internals for LD. This implies a lot of coupling between pieces, but works for now. + const source_name = source_configs[0][0]; // we happen to know that LD generates one datasource and name is first item of config + this.$refs.plotWidget.$refs.assoc_plot.callPlot((plot) => { + activateUserLD(plot, display_name, source_name); + }); + } } count_add_track(data_type); diff --git a/src/components/GwasToolbar.vue b/src/components/GwasToolbar.vue index 6df9ce8..bf12fe6 100644 --- a/src/components/GwasToolbar.vue +++ b/src/components/GwasToolbar.vue @@ -142,7 +142,7 @@ export default { class="col-sm-6">
diff --git a/src/components/TabixAdder.vue b/src/components/TabixAdder.vue index f15e978..b6aca18 100644 --- a/src/components/TabixAdder.vue +++ b/src/components/TabixAdder.vue @@ -145,10 +145,9 @@ export default { }); } else { // Not implemented for other datatypes - return {}; + resolve({}); } }); - }, createReader(event) { @@ -172,11 +171,10 @@ export default { if (data_type === DATA_TYPES.GWAS) { // GWAS files are very messy, and so knowing where to find the file is not enough. // After receiving the reader, we need to ask the user how to parse the file. (via UI) - if (filename.includes('.bed')) { + if (filename.includes('.bed') || filename.includes('.ld')) { // Check in case the user does something unwise. (2500 unique BED lines would be bad!) - throw new Error('Selected datatype GWAS does not match file extension .bed'); + throw new Error('Wrong file extension for selected data type "GWAS"'); } - this.filename = filename; this.tabix_reader = reader; this.show_gwas_modal = true; @@ -191,6 +189,9 @@ export default { } } else if (data_type === DATA_TYPES.PLINK_LD) { parser = makePlinkLdParser({normalize: true}); + if (!filename.includes('.ld')) { + throw new Error('User PLINK LD file names must include extension .ld'); + } } else { throw new Error('Unrecognized datatype'); } @@ -294,7 +295,7 @@ export default { name="data-type" value="plink_ld" > - PLINK 1.9 LD + PLINK 1.9 LD (overlay on GWAS) diff --git a/src/util/lz-helpers.js b/src/util/lz-helpers.js index c5d88e2..bda4a28 100644 --- a/src/util/lz-helpers.js +++ b/src/util/lz-helpers.js @@ -1,3 +1,4 @@ +import escape from 'lodash/escape'; import LocusZoom from 'locuszoom'; import credibleSets from 'locuszoom/esm/ext/lz-credible-sets'; import tabixSource from 'locuszoom/esm/ext/lz-tabix-source'; @@ -156,7 +157,8 @@ function createStudyLayouts (data_type, filename, display_name, annotations) { }), ]; } else if (data_type === DATA_TYPES.PLINK_LD) { - throw new Error('Not yet implemented'); + // PLINK LD is overlaid onto the plot, but not shown as its own panel + return []; } else { throw new Error('Unrecognized datatype'); } @@ -194,7 +196,9 @@ function createStudySources(data_type, tabix_reader, filename, parser_func) { [track_id, ['TabixUrlSource', {reader: tabix_reader, parser_func }]], ]; } else if (data_type === DATA_TYPES.PLINK_LD) { - throw new Error('Not yet implemented'); + return [ + [track_id, ['UserTabixLD', {reader: tabix_reader, parser_func }]], + ]; } else { throw new Error('Unrecognized datatype'); } @@ -243,12 +247,63 @@ function getBasicLayout(initial_state = {}, study_panels = [], mods = {}) { return LocusZoom.Layouts.get('plot', 'standard_association', extra); } +/** + * When user LD is received, update the plot (irreversibly) to activate custom LD features + * 1. Remove the 1000G "ld pop" toolbar button (because those pops aren't relevant for user provided LD) + * 2. Add a new toolbar button describing the study + * 3. Tell the plot to use the new LD datasource when rendering anything that uses ld + * @param {LocusZoom.Plot} plot A reference to the LZ plot instance + * @param {String} display_name Display name + */ +function activateUserLD(plot, display_name, source_id) { + const widgets = plot.toolbar.widgets; + // Remove the old ld_population toolbar widget if present + const ld_pop_index = widgets.findIndex((item) => item.layout.tag === 'ld_population'); + if (ld_pop_index !== -1) { + const ld_widget = widgets[ld_pop_index]; + ld_widget.destroy(); + widgets.splice(ld_pop_index, 1); + plot.toolbar.update(); + } + + // Add an LD description if not present + const ld_desc_index = widgets.findIndex((item) => item.layout.tag === 'user_ld_description'); + let widget; + if (ld_desc_index !== -1) { + widget = widgets[ld_desc_index]; + } else { + const config = { + tag: 'user_ld_description', + type: 'menu', + color: 'blue', + position: 'right', + button_html: 'USER LD', + menu_html: '', + }; + // Awkward API wart! + plot.layout.toolbar.widgets.push(config); + widget = plot.toolbar.addWidget(config); + } + // Update the UI help button EVERY time user LD is added, eg let user swap out LD to a different file when they switch regions + // (I'd RATHER they provide LD into all one file for regions, while still being a small file. But let's expect the most kludgy workflows to win) + const caption = `This plot is being rendered with user-provided LD. The filename is: ${escape(display_name)}`; + LocusZoom.Layouts.mutate_attrs(plot.layout, '$..widgets[?(@.tag === "user_ld_description")].menu_html', caption); + widget.show(); + + // Tell the plot to use different data as the source of LD info. Update any UI captions + LocusZoom.Layouts.mutate_attrs(plot.layout, '$..namespace.ld', source_id); + plot.mutateLayout(); + plot.toolbar.update(); + // Re-render with the new data + plot.applyState(); +} + export { // Basic definitions getBasicSources, getBasicLayout, createStudySources, createStudyLayouts, // Plot manipulation - sourceName, addPanels, + sourceName, activateUserLD, addPanels, // Constants stateUrlMapping, }; From fab97dcfc92657b451a301238f69e94b03d54b61 Mon Sep 17 00:00:00 2001 From: Andy Boughton Date: Wed, 3 Nov 2021 15:40:46 -0400 Subject: [PATCH 3/8] Add file format/usage guides based on prose created for LZ "tabix tracks" demo --- src/App.vue | 237 ++++++++++++++++++++++++++-------- src/components/TabixAdder.vue | 2 +- src/util/lz-helpers.js | 2 +- 3 files changed, 187 insertions(+), 54 deletions(-) diff --git a/src/App.vue b/src/App.vue index 0895259..5d244b3 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,7 +1,7 @@