diff --git a/esm/components/data_layer/base.js b/esm/components/data_layer/base.js index a2a0bc14..fb01365f 100644 --- a/esm/components/data_layer/base.js +++ b/esm/components/data_layer/base.js @@ -27,8 +27,9 @@ import SCALABLE from '../../registry/scalable'; * Often, the last item in the list is a string, providing a "default" value if all scale functions evaluate to null. * * @typedef {object[]|string} ScalableParameter - * @property {string} [field] The name of the field to use in the scale function. If omitted, all fields for the given - * datum element will be passed to the scale function. + * @property {String|String[]} [field] The name of the field to use in the scale function. If omitted, all fields for the given + * datum element will be passed to the scale function. For custom scale functions, an array of field names will be + * resolved into an array of values. (the scale function will receive the array as the "value" argument) * @property {module:LocusZoom_ScaleFunctions} scale_function The name of a scale function that will be run on each individual datum * @property {object} parameters A set of parameters that configure the desired scale function (options vary by function) */ @@ -528,15 +529,26 @@ class BaseDataLayer { if (option_layout.scale_function) { const func = SCALABLE.get(option_layout.scale_function); if (option_layout.field) { - const f = new Field(option_layout.field); let extra; try { extra = this.getElementAnnotation(element_data); } catch (e) { extra = null; } - ret = func(option_layout.parameters || {}, f.resolve(element_data, extra), data_index); + + const target_field = option_layout.field; + if (Array.isArray(target_field)) { + // If given an array of field names, pass the scaling function an array of values as the "data" argument + // This is primarily useful for custom scaling functions, since most built-ins assume a single scalar value + const somefields = target_field.map((afield) => (new Field(afield).resolve(element_data, extra))); + ret = func(option_layout.parameters || {}, somefields, data_index); + } else { + const f = new Field(target_field); + ret = func(option_layout.parameters || {}, f.resolve(element_data, extra), data_index); + } } else { + // If no field name is provided, pass all (namespaced) data associated with the element + // Namespaced objects are annoying to work with but this can be useful for custom code ret = func(option_layout.parameters || {}, element_data, data_index); } } 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..5c8a6f65 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]); @@ -567,34 +565,40 @@ class LDServer extends BaseApiAdapter { return dataFields; } - findRequestedFields (fields, outnames) { - // Assumption: all usages of this source will only ever ask for "isrefvar" or "state". This maps to output names. - let obj = {}; - for (let i = 0; i < fields.length; i++) { - if (fields[i] === 'isrefvar') { - obj.isrefvarin = fields[i]; - obj.isrefvarout = outnames && outnames[i]; - } else { - obj.ldin = fields[i]; - obj.ldout = outnames && outnames[i]; - } - } - return obj; + /** + * This adapter is allowed to do a very weird thing: it can make multiple HTTP requests in parallel (multiple LD refvars) + * + * Normal parsing assumes one response (text -> JSON). This source yields an array of responses that each need parsing. + */ + parseResponse(resp, ...args) { + // Perform all parsing. Returns an array of column-based data objects. + resp = resp.map((item) => (typeof item == 'string' ? JSON.parse(item) : item).data); + return super.parseResponse(resp, ...args); } /** - * The LD API payload does not obey standard format conventions; do not try to transform it. + * The LD API payload is not returned directly- instead the results are combined with the chain by performing a calculation. */ normalizeResponse (data) { + // This adapter is unique: instead of an array (one object per record), it returns an array (one object + // per overall LD response). We skip normalizing to avoid triggering logic meant to handle object-per-record code. + return data; + } + + /** + * The LD API payload is not returned directly- instead the results are combined with the chain by performing a calculation. + */ + extractFields (data) { return data; } /** - * Get the LD reference variant, which by default will be the most significant hit in the assoc results + * Get the LD reference variant(s), which by default will be the most significant hit in the assoc results * This will be used in making the original query to the LD server for pairwise LD information. * * This is meant to join a single LD request to any number of association results, and to work with many kinds of API. * To do this, the datasource looks for fields with special known names such as pvalue, log_pvalue, etc. + * It will also handle differences in variant ID format and it returns the normalized value. * If your API uses different nomenclature, an option must be specified. * * @protected @@ -617,7 +621,8 @@ class LDServer extends BaseApiAdapter { return a < b; }; } - let extremeVal = records[0][pval_field], extremeIdx = 0; + let extremeVal = records[0][pval_field]; + let extremeIdx = 0; for (let i = 1; i < records.length; i++) { if (cmp(records[i][pval_field], extremeVal)) { extremeVal = records[i][pval_field]; @@ -627,9 +632,8 @@ class LDServer extends BaseApiAdapter { return extremeIdx; }; - let reqFields = this.findRequestedFields(fields); - let refVar = reqFields.ldin; - if (refVar === 'state') { + let refVar; + if (fields.includes('state')) { refVar = state.ldrefvar || chain.header.ldrefvar || 'best'; } if (refVar === 'best') { @@ -649,27 +653,39 @@ class LDServer extends BaseApiAdapter { } refVar = chain.body[findExtremeValue(chain.body, keys.pvalue)][keys.id]; } - // Some datasets, notably the Portal, use a different marker format. - // Coerce it into one that will work with the LDServer API. (CHROM:POS_REF/ALT) - const REGEX_MARKER = /^(?:chr)?([a-zA-Z0-9]+?)[_:-](\d+)[_:|-]?(\w+)?[/_:|-]?([^_]+)?_?(.*)?/; - const match = refVar && refVar.match(REGEX_MARKER); - if (!match) { - throw new Error('Could not request LD for a missing or incomplete marker format'); - } - const [original, chrom, pos, ref, alt] = match; - // Currently, the LD server only accepts full variant specs; it won't return LD w/o ref+alt. Allowing - // a partial match at most leaves room for potential future features. - let refVar_formatted = `${chrom}:${pos}`; - if (ref && alt) { - refVar_formatted += `_${ref}/${alt}`; + if (!Array.isArray(refVar)) { + // This adapter is allowed to ask for more than one LD reference variant at the same time. + // Internally, all refVar actions are thus expressed in terms of an array of items. + refVar = [refVar]; + } else if (refVar.length === 0 || refVar.length > 4) { + throw new Error(`No more than 4 LD reference variants can be requested at the same time`); } - return [refVar_formatted, original]; + // Some datasets, notably the Portal, use a different marker format. + // Coerce each variant name into one that will work with the LDServer API. (CHROM:POS_REF/ALT) + return refVar.map((variant) => { + const REGEX_MARKER = /^(?:chr)?([a-zA-Z0-9]+?)[_:-](\d+)[_:|-]?(\w+)?[/_:|-]?([^_]+)?_?(.*)?/; + const match = variant && variant.match(REGEX_MARKER); + if (!match) { + throw new Error('Could not request LD for a missing or incomplete marker format'); + } + const [original, chrom, pos, ref, alt] = match; + // Currently, the LD server only accepts full variant specs; it won't return LD w/o ref+alt. Allowing + // a partial match at most leaves room for potential future features. + let refVar_formatted = `${chrom}:${pos}`; + if (ref && alt) { + refVar_formatted += `_${ref}/${alt}`; + } + return [refVar_formatted, original]; + }); } /** - * Identify (or guess) the LD reference variant, then add query parameters to the URL to construct a query for the specified region + * Identify (or guess) the LD reference variant(s), then add query parameters to the URL to construct a query for the specified region + * + * @returns {String[]} This adapter is unique in that it can request data for more than one LD reference variant at the same time. + * Thus, this function generates more than one URL. */ getURL(state, chain, fields) { // The LD source/pop can be overridden from plot.state for dynamic layouts @@ -685,23 +701,24 @@ class LDServer extends BaseApiAdapter { validateBuildSource(this.constructor.name, build, null); // LD doesn't need to validate `source` option - const [refVar_formatted, refVar_raw] = this.getRefvar(state, chain, fields); + const allRefVars = this.getRefvar(state, chain, fields); // Preserve the user-provided variant spec for use when matching to assoc data - chain.header.ldrefvar = refVar_raw; + // Each refVar item is a pair of [formatted_for_api_server, raw_as_seen_in_assoc_data]: + chain.header.ldrefvar = allRefVars.map((item) => item[1]); - return [ + return allRefVars.map((item) => [ this.url, 'genome_builds/', build, '/references/', source, '/populations/', population, '/variants', '?correlation=', method, - '&variant=', encodeURIComponent(refVar_formatted), + '&variant=', encodeURIComponent(item[0]), '&chrom=', encodeURIComponent(state.chr), '&start=', encodeURIComponent(state.start), '&stop=', encodeURIComponent(state.end), - ].join(''); + ].join('')); } /** - * The LD adapter caches based on region, reference panel, and population name + * The LD adapter caches based on region, reference panel, population name, and the requested reference variant(s) * @param state * @param chain * @param fields @@ -711,68 +728,94 @@ class LDServer extends BaseApiAdapter { const base = super.getCacheKey(state, chain, fields); let source = state.ld_source || this.params.source || '1000G'; const population = state.ld_pop || this.params.population || 'ALL'; // LDServer panels will always have an ALL - const [refVar, _] = this.getRefvar(state, chain, fields); - return `${base}_${refVar}_${source}_${population}`; + const allRefVars = this.getRefvar(state, chain, fields); + const refvar_str = allRefVars.map((item) => item[0]).join('_'); + return `${base}_${refvar_str}_${source}_${population}`; } /** * The LD adapter attempts to intelligently match retrieved LD information to a request for association data earlier in the data chain. * Since each layer only asks for the data needed for that layer, one LD call is sufficient to annotate many separate association tracks. + * + * If more than one LD reference variant is specified, then the association data point is annotated with the highest LD value and the identifier for that variant. */ combineChainBody(data, chain, fields, outnames, trans) { - let keys = this.findMergeFields(chain); - let reqFields = this.findRequestedFields(fields, outnames); - if (!keys.position) { - throw new Error(`Unable to find position field for merge: ${keys._names_}`); + let assoc_field_names = this.findMergeFields(chain); + // This will annotate every assoc value (from chain.body) with special fields (where `ld` represents the namespace in use): + // - ld:isrefvar (tag if this is a reference variant) + // - ld:state (the correlation value, like r2. This is a terrible variable name but it's been copied and pasted + // into a lot of LZ layouts on different websites, and now we're stuck with it. + const namespace = this.source_id || this.constructor.name; + // Names of fields in output, eg ld:state, ld:isrefvar.... + const out_correl_field = `${namespace}:state`; + const out_refvar_tag = `${namespace}:isrefvar`; + const out_varname_tag = `${namespace}:refvarname`; // When there are multiple LD refvars in use, which is this relative to? + + if (!assoc_field_names.position) { + throw new Error(`Unable to find position field for merge: ${assoc_field_names._names_}`); } - const leftJoin = function (left, right, lfield, rfield) { - let i = 0, j = 0; - while (i < left.length && j < right.position2.length) { - if (left[i][keys.position] === right.position2[j]) { - left[i][lfield] = right[rfield][j]; + const leftJoin = function (assoc_data, ...ld_responses) { + // left = assoc data. Field names may vary (many data sources and no good "contract" for data) + // right = array of LD data responses, one per variant. This adapter makes fairly rigid field name + // assumptions based on the UM LDServer API- there aren't a lot of dynamic LD calculators and life + // is easier when data is predictable + let i = 0; + let j = 0; + // The join code assumes that for the same REGION, PANEL, and POPULATION, all LD responses for + // different refvars will contain the same set of variants, including the requested refvar (for which + // LD will be reported as 1 relative to itself) + const first_variant_positions = ld_responses[0].position2; + const variant_names = ld_responses.map((item) => item.variant1[0]); // for single-reference queries, the entire variant1 column is redundant + + const refvars = new Set(chain.header.ldrefvar); + + while (i < assoc_data.length && j < first_variant_positions.length) { + const assoc_row = assoc_data[i]; + if (assoc_data[i][assoc_field_names.position] === first_variant_positions[j]) { + const this_row_correlations = ld_responses.map((var_data) => var_data.correlation[j]); + + const best_corr = Math.max(...this_row_correlations); + assoc_row[out_correl_field] = best_corr; + assoc_row[out_varname_tag] = variant_names[this_row_correlations.findIndex((val) => val === best_corr)]; + if (refvars.has(assoc_row[assoc_field_names.id])) { + // It's possible to have LD = 1, so we need a separate field to explicitly tag the refvar + assoc_row[out_refvar_tag] = 1; + } + i++; j++; - } else if (left[i][keys.position] < right.position2[j]) { + } else if (assoc_row[assoc_field_names.position] < first_variant_positions[j]) { i++; } else { j++; } } }; - const tagRefVariant = function (data, refvar, idfield, outrefname, outldname) { - for (let i = 0; i < data.length; i++) { - if (data[i][idfield] && data[i][idfield] === refvar) { - data[i][outrefname] = 1; - data[i][outldname] = 1; // For label/filter purposes, implicitly mark the ref var as LD=1 to itself - } else { - data[i][outrefname] = 0; - } - } - }; - // LD servers vary slightly. Some report corr as "rsquare", others as "correlation" - let corrField = data.rsquare ? 'rsquare' : 'correlation'; - leftJoin(chain.body, data, reqFields.ldout, corrField); - if (reqFields.isrefvarin && chain.header.ldrefvar) { - tagRefVariant(chain.body, chain.header.ldrefvar, keys.id, reqFields.isrefvarout, reqFields.ldout); - } + leftJoin(chain.body, ...data); return chain.body; } /** + * This adapter is very unique, in that it can combine MULTIPLE requests to get the data it needs: + * - The user can specify more than one LD reference variant ("color assoc data by thing it is in highest LD with") * The LDServer API is paginated, but we need all of the data to render a plot. Depaginate and combine where appropriate. + * - + * @returns {Promise} A promise that resolves to an array of one-depaginated-response-per-variant. Responses are already + * parsed, during the "detect more data and depaginate" phase. */ fetchRequest(state, chain, fields) { - let url = this.getURL(state, chain, fields); - let combined = { data: {} }; + let urls = this.getURL(state, chain, fields); + let chainRequests = function (url) { - return fetch(url).then().then((response) => { + // Depaginate requests for a single API request + let combined = { data: {} }; + return fetch(url).then((response) => { if (!response.ok) { throw new Error(response.statusText); } - return response.text(); + return response.json(); }).then(function(payload) { - payload = JSON.parse(payload); Object.keys(payload.data).forEach(function (key) { combined.data[key] = (combined.data[key] || []).concat(payload.data[key]); }); @@ -782,10 +825,11 @@ class LDServer extends BaseApiAdapter { return combined; }); }; - return chainRequests(url); + return Promise.all(urls.map((url) => chainRequests(url))); } } + /** * 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..aeeae38d --- /dev/null +++ b/esm/ext/lz-multi-ld.js @@ -0,0 +1,181 @@ +/** + * 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 + */ + +import * as d3 from 'd3'; + + +function install(LocusZoom) { + + + /** + * (**extension**) Color an LD reference variant based on two fields: LD refvar, and correlation value + * + * @alias module:LocusZoom_ScaleFunctions~multi_ld_bins + * @param {Object} parameters This function has no defined configuration options + * @param {Array} parameters.categories Array of possible category names, eg "variant1, variant2" + * Each category has its own set of possible output `values` + * @param {Number[]} parameters.breaks Array of numerical breakpoints against which to evaluate the input value + * Must be of equal length as each possible set of options in values (eg, values[0].length) + * @param {Array} parameters.values Array of possible sets of return values: each category has its own set of values. + * Once category is determined, the set of options is evaluated relative to breakpoints. + * "Values" must be a nested array with the same number of entries as `categories`, and each array in values should be an array with the same number of entries as `breaks`. + * Each entry n represents the value to return if the input value is greater than + * or equal to break n and less than or equal to break n+1 (or break n+1 doesn't exist). + * + * @param {Array} item_data An array containing two field values that will be resolved into an output: [category_name, numerical_value] + * @see {@link module:ext/lz-multi-ld} for required extension and installation instructions + */ + const ld_multi_bin = function (parameters, item_data) { + const [category_field, value_field] = item_data; + if (!category_field) { + // In multi-LD, some variants won't have LD info, and thus they won't match any category + return null; + } + + const categories = parameters.categories; + const breaks = parameters.breaks; + const category_to_use = categories.indexOf(category_field); + if (category_to_use === -1) { + return null; + } + const values = parameters.values[category_to_use]; + + if (typeof value_field == 'undefined' || value_field === null || isNaN(+value_field)) { + return null; + } + const threshold = breaks.reduce(function (prev, curr) { + if (+value_field < prev || (+value_field >= prev && +value_field < curr)) { + return prev; + } else { + return curr; + } + }); + return values[breaks.indexOf(threshold)]; + }; + + 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: d3.schemeBlues[9].slice(2, 7), + 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: d3.schemeGreens[9].slice(2, 7), + label_size: 10, + }, + { + shape: 'ribbon', + label: 'SNP Red', + width: 30, + height: 5, + color_stops: d3.schemeOranges[9].slice(2, 7), + label_size: 10, + }, + { + shape: 'ribbon', + label: 'SNP Purple', + width: 30, + height: 5, + color_stops: d3.schemePurples[9].slice(2, 7), + 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' }, + ], + color: [ + { + scale_function: 'if', + field: '{{namespace[ld]}}isrefvar', + parameters: { + field_value: 1, + then: '#9632b8', + }, + }, + { + scale_function: 'ld_multi_bin', + field: ['{{namespace[ld]}}refvarname', '{{namespace[ld]}}state'], + parameters: { + categories: ['10:114734096_A/G', '10:114758349_C/T', '10:114788436_C/T', '10:114861304_A/G'], + breaks: [0, 0.2, 0.4, 0.6, 0.8, 1.0], + values: [ + d3.schemeBlues[9].slice(2, 7), + d3.schemeGreens[9].slice(2, 7), + d3.schemeOranges[9].slice(2, 7), + d3.schemePurples[9].slice(2, 7), + ], + }, + }, + '#B8B8B8', + ], + }); + + 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.ScaleFunctions.add('ld_multi_bin', ld_multi_bin); + 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..a44a376c --- /dev/null +++ b/examples/misc/ext-multi_ld.html @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + 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/test/unit/data/test_adapters.js b/test/unit/data/test_adapters.js index e3bf2231..d8f60ff5 100644 --- a/test/unit/data/test_adapters.js +++ b/test/unit/data/test_adapters.js @@ -479,17 +479,18 @@ describe('Data adapters', function () { it('will prefer a refvar in plot.state if one is provided', function () { const source = new LDServer({ url: 'www.fake.test', params: { build: 'GRCh37' } }); - const [ref, _] = source.getRefvar( + const urls = source.getRefvar( { ldrefvar: '12:100_A/C' }, { header: {}, body: [{ id: 'a', pvalue: 0 }] }, ['ldrefvar', 'state'] ); + const [ref, _] = urls[0]; assert.equal(ref, '12:100_A/C'); }); it('auto-selects the best reference variant (lowest pvalue)', function () { const source = new LDServer({ url: 'www.fake.test', params: { build: 'GRCh37' } }); - const [ref, _] = source.getRefvar( + const allRefVars = source.getRefvar( {}, { header: {}, @@ -501,12 +502,14 @@ describe('Data adapters', function () { }, ['isrefvar', 'state'] ); + + const [ref, _] = allRefVars[0]; assert.equal(ref, '12:100_A/B'); }); it('auto-selects the best reference variant (largest nlog_pvalue)', function () { const source = new LDServer({ url: 'www.fake.test', params: { build: 'GRCh37' } }); - const [ref, _] = source.getRefvar( + const allRefVars = source.getRefvar( {}, { header: {}, @@ -518,6 +521,7 @@ describe('Data adapters', function () { }, ['isrefvar', 'state'] ); + const [ref, _] = allRefVars[0]; assert.equal(ref, '12:100_A/B'); }); @@ -557,10 +561,10 @@ describe('Data adapters', function () { const request_url = source.getURL({ ldrefvar: portal_format }, { header: {}, body: [], - }, ['isrefvar', 'state']); + }, ['isrefvar', 'state'])[0]; assert.equal( request_url, - source.getURL({ ldrefvar: ldserver_format }, { header: {}, body: [] }, ['isrefvar', 'state']) + source.getURL({ ldrefvar: ldserver_format }, { header: {}, body: [] }, ['isrefvar', 'state'])[0] ); assert.ok(request_url.includes(encodeURIComponent(ldserver_format))); }); @@ -572,10 +576,10 @@ describe('Data adapters', function () { const request_url = source.getURL({ ldrefvar: norefvar_topmed }, { header: {}, body: [], - }, ['isrefvar', 'state']); + }, ['isrefvar', 'state'])[0]; assert.equal( request_url, - source.getURL({ ldrefvar: ldserver_format }, { header: {}, body: [] }, ['isrefvar', 'state']) + source.getURL({ ldrefvar: ldserver_format }, { header: {}, body: [] }, ['isrefvar', 'state'])[0] ); assert.ok(request_url.includes(encodeURIComponent(ldserver_format))); }); diff --git a/test/unit/ext/test_multi_ld.js b/test/unit/ext/test_multi_ld.js new file mode 100644 index 00000000..501f02b6 --- /dev/null +++ b/test/unit/ext/test_multi_ld.js @@ -0,0 +1,29 @@ +import {assert} from 'chai'; + +import LocusZoom from 'locuszoom'; +import {SCALABLE} from '../../../esm/registry'; + +import ld_plugin from '../../../esm/ext/lz-multi-ld'; + +LocusZoom.use(ld_plugin); + +describe('Multi LD plugin', function () { + describe('ld_multi_bin scale function', function () { + it('assigns LD by category', function () { + const options = { + categories: ['a', 'b', 'c'], + breaks: [1, 2, 3], + values: [ + ['a', 'aa', 'aaa'], + ['b', 'bb', 'bbb'], + ['c', 'cc', 'ccc'], + ], + }; + + const func = SCALABLE.get('ld_multi_bin'); + assert.equal(func(options, ['b', 2.2]), 'bb', 'Finds a value based on matching category'); + assert.equal(func(options, ['squid', 2.2]), null, 'Returns null if category is not a match'); + assert.equal(func(options, [null, 2.2]), null, 'Returns null if category is not provided'); + }); + }); +}); 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