/**
* Helpers that control the display of individual points and field values
* @module
* @private
*/
import * as d3 from 'd3';
import Field from '../data/field';
import Plot from '../components/plot';
import {applyStyles} from './common';
/**
* Convert an integer chromosome position to an SI string representation (e.g. 23423456 => "23.42" (Mb))
* @param {Number} pos Position
* @param {Number} [exp] Exponent to use for the returned string, eg 6=> MB. If not specified, will attempt to guess
* the most appropriate SI prefix based on the number provided.
* @param {Boolean} [suffix=false] Whether or not to append a suffix (e.g. "Mb") to the end of the returned string
* @returns {string}
*/
function positionIntToString(pos, exp, suffix) {
const exp_symbols = { 0: '', 3: 'K', 6: 'M', 9: 'G' };
suffix = suffix || false;
if (isNaN(exp) || exp === null) {
const log = Math.log(pos) / Math.LN10;
exp = Math.min(Math.max(log - (log % 3), 0), 9);
}
const places_exp = exp - Math.floor((Math.log(pos) / Math.LN10).toFixed(exp + 3));
const min_exp = Math.min(Math.max(exp, 0), 2);
const places = Math.min(Math.max(places_exp, min_exp), 12);
let ret = `${(pos / Math.pow(10, exp)).toFixed(places)}`;
if (suffix && typeof exp_symbols[exp] !== 'undefined') {
ret += ` ${exp_symbols[exp]}b`;
}
return ret;
}
/**
* Convert an SI string chromosome position to an integer representation (e.g. "5.8 Mb" => 58000000)
* @param {String} p The chromosome position
* @returns {Number}
*/
function positionStringToInt(p) {
let val = p.toUpperCase();
val = val.replace(/,/g, '');
const suffixre = /([KMG])[B]*$/;
const suffix = suffixre.exec(val);
let mult = 1;
if (suffix) {
if (suffix[1] === 'M') {
mult = 1e6;
} else if (suffix[1] === 'G') {
mult = 1e9;
} else {
mult = 1e3; //K
}
val = val.replace(suffixre, '');
}
val = Number(val) * mult;
return val;
}
/**
* Generate a "pretty" set of ticks (multiples of 1, 2, or 5 on the same order of magnitude for the range)
* Based on R's "pretty" function: https://github.com/wch/r-source/blob/b156e3a711967f58131e23c1b1dc1ea90e2f0c43/src/appl/pretty.c
* @param {Number[]} range A two-item array specifying [low, high] values for the axis range
* @param {('low'|'high'|'both'|'neither')} [clip_range='neither'] What to do if first and last generated ticks extend
* beyond the range. Set this to "low", "high", "both", or "neither" to clip the first (low) or last (high) tick to
* be inside the range or allow them to extend beyond.
* e.g. "low" will clip the first (low) tick if it extends beyond the low end of the range but allow the
* last (high) tick to extend beyond the range. "both" clips both ends, "neither" allows both to extend beyond.
* @param {Number} [target_tick_count=5] The approximate number of ticks you would like to be returned; may not be exact
* @returns {Number[]}
*/
function prettyTicks(range, clip_range, target_tick_count) {
if (typeof target_tick_count == 'undefined' || isNaN(parseInt(target_tick_count))) {
target_tick_count = 5;
}
target_tick_count = +target_tick_count;
const min_n = target_tick_count / 3;
const shrink_sml = 0.75;
const high_u_bias = 1.5;
const u5_bias = 0.5 + 1.5 * high_u_bias;
const d = Math.abs(range[0] - range[1]);
let c = d / target_tick_count;
if ((Math.log(d) / Math.LN10) < -2) {
c = (Math.max(Math.abs(d)) * shrink_sml) / min_n;
}
const base = Math.pow(10, Math.floor(Math.log(c) / Math.LN10));
let base_toFixed = 0;
if (base < 1 && base !== 0) {
base_toFixed = Math.abs(Math.round(Math.log(base) / Math.LN10));
}
let unit = base;
if ( ((2 * base) - c) < (high_u_bias * (c - unit)) ) {
unit = 2 * base;
if ( ((5 * base) - c) < (u5_bias * (c - unit)) ) {
unit = 5 * base;
if ( ((10 * base) - c) < (high_u_bias * (c - unit)) ) {
unit = 10 * base;
}
}
}
let ticks = [];
let i = parseFloat((Math.floor(range[0] / unit) * unit).toFixed(base_toFixed));
while (i < range[1]) {
ticks.push(i);
i += unit;
if (base_toFixed > 0) {
i = parseFloat(i.toFixed(base_toFixed));
}
}
ticks.push(i);
if (typeof clip_range == 'undefined' || ['low', 'high', 'both', 'neither'].indexOf(clip_range) === -1) {
clip_range = 'neither';
}
if (clip_range === 'low' || clip_range === 'both') {
if (ticks[0] < range[0]) {
ticks = ticks.slice(1);
}
}
if (clip_range === 'high' || clip_range === 'both') {
if (ticks[ticks.length - 1] > range[1]) {
ticks.pop();
}
}
return ticks;
}
/**
* Replace placeholders in an html string with field values defined in a data object
* Only works on scalar values in data! Will ignore non-scalars. This is useful in, eg, tooltip templates.
*
* NOTE: Trusts content exactly as given. XSS prevention is the responsibility of the implementer.
* @param {String} html A placeholder string in which to substitute fields. Supports several template options:
* `{{field_name}}` is a variable placeholder for the value of `field_name` from the provided data
* `{{#if field_name}} Conditional text {{/if}}` will insert the contents of the tag only if the value exists.
* This can be used with namespaced values, `{{#if assoc:field}}`; any dynamic namespacing will be applied when the
* layout is first retrieved. For numbers, transforms like `{{#if field|is_numeric}}` can help to ensure that 0
* values are displayed when expected.
* Can optionally take an else block, useful for things like toggle buttons: {{#if field}} ... {{#else}} ... {{/if}}
* @param {Object} data The data associated with a particular element. Eg, tooltips often appear over a specific point.
* @param {Object|null} extra Any additional fields (eg element annotations) associated with the specified datum
* @returns {string}
*/
function parseFields(html, data, extra) {
if (typeof data != 'object') {
throw new Error('invalid arguments: data is not an object');
}
if (typeof html != 'string') {
throw new Error('invalid arguments: html is not a string');
}
// `tokens` is like [token,...]
// `token` is like {text: '...'} or {variable: 'foo|bar'} or {condition: 'foo|bar'} or {close: 'if'}
const tokens = [];
const regex = /{{(?:(#if )?([\w+_:|]+)|(#else)|(\/if))}}/;
while (html.length > 0) {
const m = regex.exec(html);
if (!m) {
tokens.push({text: html});
html = '';
} else if (m.index !== 0) {
tokens.push({text: html.slice(0, m.index)});
html = html.slice(m.index);
} else if (m[1] === '#if ') {
tokens.push({condition: m[2]});
html = html.slice(m[0].length);
} else if (m[2]) {
tokens.push({variable: m[2]});
html = html.slice(m[0].length);
} else if (m[3] === '#else') {
tokens.push({branch: 'else'});
html = html.slice(m[0].length);
} else if (m[4] === '/if') {
tokens.push({close: 'if'});
html = html.slice(m[0].length);
} else {
console.error(`Error tokenizing tooltip when remaining template is ${JSON.stringify(html)} and previous tokens are ${JSON.stringify(tokens)} and current regex match is ${JSON.stringify([m[1], m[2], m[3]])}`);
html = html.slice(m[0].length);
}
}
const astify = function () {
const token = tokens.shift();
if (typeof token.text !== 'undefined' || token.variable) {
return token;
} else if (token.condition) {
let dest = token.then = [];
token.else = [];
// Inside an if block, consume all tokens related to text and/or else block
while (tokens.length > 0) {
if (tokens[0].close === 'if') {
tokens.shift();
break;
}
if (tokens[0].branch === 'else') {
tokens.shift();
dest = token.else;
}
dest.push(astify());
}
return token;
} else {
console.error(`Error making tooltip AST due to unknown token ${JSON.stringify(token)}`);
return { text: '' };
}
};
// `ast` is like [thing,...]
// `thing` is like {text: "..."} or {variable:"foo|bar"} or {condition: "foo|bar", then:[thing,...]}
const ast = [];
while (tokens.length > 0) {
ast.push(astify());
}
const resolve = function (variable) {
if (!Object.prototype.hasOwnProperty.call(resolve.cache, variable)) {
resolve.cache[variable] = (new Field(variable)).resolve(data, extra);
}
return resolve.cache[variable];
};
resolve.cache = {};
const render_node = function (node) {
if (typeof node.text !== 'undefined') {
return node.text;
} else if (node.variable) {
try {
const value = resolve(node.variable);
if (['string', 'number', 'boolean'].indexOf(typeof value) !== -1) {
return value;
}
if (value === null) {
return '';
}
} catch (error) {
console.error(`Error while processing variable ${JSON.stringify(node.variable)}`);
}
return `{{${node.variable}}}`;
} else if (node.condition) {
try {
const condition = resolve(node.condition);
if (condition) {
return node.then.map(render_node).join('');
} else if (node.else) {
return node.else.map(render_node).join('');
}
} catch (error) {
console.error(`Error while processing condition ${JSON.stringify(node.variable)}`);
}
return '';
} else {
console.error(`Error rendering tooltip due to unknown AST node ${JSON.stringify(node)}`);
}
};
return ast.map(render_node).join('');
}
/**
* Populate a single element with a LocusZoom plot. This is the primary means of generating a new plot, and is part
* of the public interface for LocusZoom.
* @alias module:LocusZoom~populate
* @public
* @param {String|d3.selection} selector CSS selector for the container element where the plot will be mounted. Any pre-existing
* content in the container will be completely replaced.
* @param {module:LocusZoom~DataSources} datasource Ensemble of data providers used by the plot
* @param {Object} layout A JSON-serializable object of layout configuration parameters
* @returns {Plot} The newly created plot instance
*/
function populate(selector, datasource, layout) {
if (typeof selector == 'undefined') {
throw new Error('LocusZoom.populate selector not defined');
}
// Empty the selector of any existing content
d3.select(selector).html('');
let plot;
d3.select(selector).call(function(target) {
// Require each containing element have an ID. If one isn't present, create one.
if (typeof target.node().id == 'undefined') {
let iterator = 0;
while (!d3.select(`#lz-${iterator}`).empty()) {
iterator++;
}
target.attr('id', `#lz-${iterator}`);
}
// Create the plot
plot = new Plot(target.node().id, datasource, layout);
plot.container = target.node();
// Detect HTML `data-region` attribute, and use it to fill in state values if present
if (typeof target.node().dataset !== 'undefined' && typeof target.node().dataset.region !== 'undefined') {
const parsed_state = parsePositionQuery(target.node().dataset.region);
Object.keys(parsed_state).forEach(function(key) {
plot.state[key] = parsed_state[key];
});
}
// Add an SVG to the div and set its dimensions
plot.svg = d3.select(`div#${plot.id}`)
.append('svg')
.attr('version', '1.1')
.attr('xmlns', 'http://www.w3.org/2000/svg')
.attr('id', `${plot.id}_svg`)
.attr('class', 'lz-locuszoom')
.call(applyStyles, plot.layout.style);
plot.setDimensions();
plot.positionPanels();
// Initialize the plot
plot.initialize();
// If the plot has defined data sources then trigger its first mapping based on state values
if (datasource) {
plot.refresh();
}
});
return plot;
}
/**
* Parse region queries into their constituent parts
* @param {String} x A chromosome position query. May be any of the forms `chr:start-end`, `chr:center+offset`,
* or `chr:pos`
* @returns {{chr:*, start: *, end:*} | {chr:*, position:*}}
*/
function parsePositionQuery(x) {
const chrposoff = /^(\w+):([\d,.]+[kmgbKMGB]*)([-+])([\d,.]+[kmgbKMGB]*)$/;
const chrpos = /^(\w+):([\d,.]+[kmgbKMGB]*)$/;
let match = chrposoff.exec(x);
if (match) {
if (match[3] === '+') {
const center = positionStringToInt(match[2]);
const offset = positionStringToInt(match[4]);
return {
chr:match[1],
start: center - offset,
end: center + offset,
};
} else {
return {
chr: match[1],
start: positionStringToInt(match[2]),
end: positionStringToInt(match[4]),
};
}
}
match = chrpos.exec(x);
if (match) {
return {
chr:match[1],
position: positionStringToInt(match[2]),
};
}
return null;
}
export { parseFields, parsePositionQuery, populate, positionIntToString, positionStringToInt, prettyTicks };