From 39c0923dc5c168f11073e91d1730b83f1fd6bdd1 Mon Sep 17 00:00:00 2001 From: Andy Boughton Date: Thu, 11 Mar 2021 15:02:29 -0500 Subject: [PATCH 1/5] Implement "ribbon" type legends, eg, for LD information + Start work on multi-LD extension feature --- esm/components/legend.js | 49 ++++++++++++++- esm/data/adapters.js | 23 ++++--- esm/ext/lz-multi-ld.js | 104 ++++++++++++++++++++++++++++++++ esm/layouts/index.js | 1 - examples/misc/ext-multi_ld.html | 101 +++++++++++++++++++++++++++++++ webpack.common.cjs | 18 +++--- 6 files changed, 277 insertions(+), 19 deletions(-) create mode 100644 esm/ext/lz-multi-ld.js create mode 100644 examples/misc/ext-multi_ld.html diff --git a/esm/components/legend.js b/esm/components/legend.js index be5d3ed3..6b25773e 100644 --- a/esm/components/legend.js +++ b/esm/components/legend.js @@ -100,8 +100,9 @@ class Legend { let y = padding; let line_height = 0; this.parent.data_layer_ids_by_z_index.slice().reverse().forEach((id) => { - if (Array.isArray(this.parent.data_layers[id].layout.legend)) { - this.parent.data_layers[id].layout.legend.forEach((element) => { + const layer_legend = this.parent.data_layers[id].layout.legend; + if (Array.isArray(layer_legend)) { + layer_legend.forEach((element) => { const selector = this.elements_group.append('g') .attr('transform', `translate(${x}, ${y})`); const label_size = +element.label_size || +this.layout.label_size || 12; @@ -135,6 +136,50 @@ class Legend { label_x = width + padding; line_height = Math.max(line_height, height + padding); + } else if (shape === 'ribbon') { + // Color ribbons describe a series of color stops: small boxes of color across a continuous + // scale. Drawn like: + // [red | orange | yellow | green ] label + // For example, this can be used with the numerical-bin color scale to describe LD color stops in a compact way. + const width = +element.width || 8; + const height = +element.height || width; + const color_stops = element.color_stops; + const ribbon_group = selector.append('g'); + let axis_offset = 0; + if (element.tick_labels) { + const scale = d3.scaleLinear() + .domain(d3.extent(element.tick_labels)) // Assumes tick labels are always numeric in this mode + .range([0, width * color_stops.length - 1]); // 1 px offset to align tick with inner borders + const axis = d3.axisTop(scale) + .tickSize(3) + .tickValues(element.tick_labels) + .tickFormat((v) => v); + ribbon_group.call(axis); + axis_offset += ribbon_group.node().getBoundingClientRect().height; + } + ribbon_group + .attr('transform', `translate(${0}, ${axis_offset})`); + + for (let i = 0; i < color_stops.length; i++) { + const color = color_stops[i]; + ribbon_group + .append('rect') + .attr('class', element.class || '') + .attr('stroke', 'black') + .attr('transform', `translate(${width * i}, 0)`) + .attr('stroke-width', 0.5) + .attr('width', width) + .attr('height', height) + .attr('fill', color) + .call(applyStyles, element.style || {}); + } + + label_x = width * color_stops.length + padding; + label_y += axis_offset; + // { + // shape: 'ribbon', label: _, style: _, + // color-stops: [], ticks: [] + // } } else if (shape_factory) { // Shape symbol is a recognized d3 type, so we can draw it in the legend (circle, diamond, etc.) const size = +element.size || 40; diff --git a/esm/data/adapters.js b/esm/data/adapters.js index deca8120..21603a1a 100644 --- a/esm/data/adapters.js +++ b/esm/data/adapters.js @@ -530,16 +530,14 @@ class LDServer extends BaseApiAdapter { // Since LD information may be shared across multiple assoc sources with different namespaces, // we use regex to find columns to join on, rather than requiring exact matches - const exactMatch = function (arr) { + const exactMatch = function (field_names) { return function () { const regexes = arguments; for (let i = 0; i < regexes.length; i++) { const regex = regexes[i]; - const m = arr.filter(function (x) { - return x.match(regex); - }); - if (m.length) { - return m[0]; + const m = field_names.find((x) => x.match(regex)); + if (m) { + return m; } } return null; @@ -549,7 +547,7 @@ class LDServer extends BaseApiAdapter { id: this.params.id_field, position: this.params.position_field, pvalue: this.params.pvalue_field, - _names_:null, + _names_: null, }; if (chain && chain.body && chain.body.length > 0) { const names = Object.keys(chain.body[0]); @@ -766,7 +764,7 @@ class LDServer extends BaseApiAdapter { let url = this.getURL(state, chain, fields); let combined = { data: {} }; let chainRequests = function (url) { - return fetch(url).then().then((response) => { + return fetch(url).then((response) => { if (!response.ok) { throw new Error(response.statusText); } @@ -786,6 +784,15 @@ class LDServer extends BaseApiAdapter { } } +// +// class LDServerMulti extends LDServer { +// // getURL is relative to refvar +// // One fetchRequest per variant.... +// // So essentially fetchRequest needs to return one or more items, and combineChainBody needs to combine one or more items +// // Parsing also needs to handle a series of promises, not just one +// } + + /** * Fetch GWAS catalog data for a list of known variants, and align the data with previously fetched association data. * There can be more than one claim per variant; this adapter is written to support a visualization in which each diff --git a/esm/ext/lz-multi-ld.js b/esm/ext/lz-multi-ld.js new file mode 100644 index 00000000..48fd79f1 --- /dev/null +++ b/esm/ext/lz-multi-ld.js @@ -0,0 +1,104 @@ +/** + * Widgets and layouts for showing LD relative to more than one variant + * + * + * ### Features provided + * * TODO: Write this + * + * ### Loading and usage + * The page must incorporate and load all libraries before this file can be used, including: + * - Vendor assets + * - LocusZoom + * + * To use in an environment without special JS build tooling, simply load the extension file as JS from a CDN (after any dependencies): + * ``` + * + * ``` + * + * To use with ES6 modules, the plugin must be loaded and registered explicitly before use: + * ``` + * import LocusZoom from 'locuszoom'; + * import LzMultiLD from 'locuszoom/esm/ext/lz-multi-ld'; + * LocusZoom.use(LzMultiLD); + * ``` + * + * Then use the widgets and layouts provided by this extension + * + * @module + */ + +function install(LocusZoom) { + const assoc_pvalues_multi_ld_layer = LocusZoom.Layouts.get('data_layer', 'association_pvalues', { + unnamespaced:true, + legend: [ + { + shape: 'ribbon', + label: 'One SNP', + width: 30, + height: 5, + color_stops: ['#357ebd', '#46b8da', '#5cb85c', '#eea236', '#d43f3a'], + tick_labels: [0, 0.2, 0.4, 0.6, 0.8, 1.0], + label_size: 10, + }, + { + shape: 'ribbon', + label: 'SNP Blue', + width: 30, + height: 5, + // color_stops: ['#357ebd', '#46b8da', '#5cb85c', '#eea236', '#d43f3a'], + color_stops: ['#eff3ff', '#bdd7e7', '#6baed6', '#3182bd', '#08519c'], + tick_labels: [0, 0.2, 0.4, 0.6, 0.8, 1.0], + label_size: 10, + }, + { + shape: 'ribbon', + label: 'SNP Green', + width: 30, + height: 5, + color_stops: ['#edf8e9', '#bae4b3', '#74c476', '#31a354', '#006d2c'], + label_size: 10, + }, + { + shape: 'ribbon', + label: 'SNP Red', + width: 30, + height: 5, + color_stops: ['#feedde', '#fdbe85', '#fd8d3c', '#e6550d', '#a63603'], + label_size: 10, + }, + { + shape: 'ribbon', + label: 'SNP Purple', + width: 30, + height: 5, + color_stops: ['#f2f0f7', '#cbc9e2', '#9e9ac8', '#756bb1', '#54278f'], + label_size: 10, + }, + { shape: 'diamond', color: '#9632b8', size: 40, label: 'LD Ref Var', label_size: 10, class: 'lz-data_layer-scatter' }, + { shape: 'circle', color: '#B8B8B8', size: 40, label: 'no r² data', label_size: 10, class: 'lz-data_layer-scatter' }, + ], + }); + + const assoc_pvalues_multi_ld_panel = function () { + const base = LocusZoom.Layouts.get('panel', 'association', { + height: 300, + legend: { padding: 4, hidden: false }, + }); + // Replace standard assoc panel with multi LD version. + base.data_layers[2] = assoc_pvalues_multi_ld_layer; + return base; + }(); + + LocusZoom.Layouts.add('data_layer', 'assoc_pvalues_multi_ld', assoc_pvalues_multi_ld_layer); + LocusZoom.Layouts.add('panel', 'association_multi_ld', assoc_pvalues_multi_ld_panel); +} + +if (typeof LocusZoom !== 'undefined') { + // Auto-register the plugin when included as a script tag. ES6 module users must register via LocusZoom.use() + // eslint-disable-next-line no-undef + LocusZoom.use(install); +} + + +export default install; + diff --git a/esm/layouts/index.js b/esm/layouts/index.js index 2f4b0b3c..87e6b5e3 100644 --- a/esm/layouts/index.js +++ b/esm/layouts/index.js @@ -695,7 +695,6 @@ const association_panel = { legend: { orientation: 'vertical', origin: { x: 55, y: 40 }, - hidden: true, }, interaction: { drag_background_to_pan: true, diff --git a/examples/misc/ext-multi_ld.html b/examples/misc/ext-multi_ld.html new file mode 100644 index 00000000..a03e150f --- /dev/null +++ b/examples/misc/ext-multi_ld.html @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + LocusZoom.js ~ Multiple LD Reference Variants + + + + + + +
+ +

LocusZoom.js

+ +

Multiple LD Reference Variants

+
< return home
+ +
+ +

Users can choose to calculate LD based on more than one reference variant. This is very + useful when there are multiple (independent) signals in the same region.

+
+ +
+ +
+ +
+ + + +
+ + + diff --git a/webpack.common.cjs b/webpack.common.cjs index 0f6836a3..b01612a9 100644 --- a/webpack.common.cjs +++ b/webpack.common.cjs @@ -15,14 +15,15 @@ const outputPath = path.resolve(__dirname, 'dist'); const FILENAMES = { // For legacy reasons, the filenames that people expect are different than the "library" name LocusZoom: 'locuszoom.app.min.js', + LzAggregationTests: 'ext/lz-aggregation-tests.min.js', + LzCredibleSets: 'ext/lz-credible-sets.min.js', LzDynamicUrls: 'ext/lz-dynamic-urls.min.js', - LzWidgetAddons: 'ext/lz-widget-addons.min.js', LzForestTrack: 'ext/lz-forest-track.min.js', - LzIntervalsTrack: 'ext/lz-intervals-track.min.js', LzIntervalsEnrichment: 'ext/lz-intervals-enrichment.min.js', - LzCredibleSets: 'ext/lz-credible-sets.min.js', + LzIntervalsTrack: 'ext/lz-intervals-track.min.js', + LzMultiLD: 'ext/lz-multi-ld.min.js', LzTabix: 'ext/lz-tabix-source.min.js', - LzAggregationTests: 'ext/lz-aggregation-tests.min.js', + LzWidgetAddons: 'ext/lz-widget-addons.min.js', }; module.exports = { @@ -30,14 +31,15 @@ module.exports = { entry: { // When a