Source: helpers/display.js

/**
 * 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 };