Source: components/data_layer/highlight_regions.js

import * as d3 from 'd3';

import BaseDataLayer from './base';
import {merge} from '../../helpers/layouts';

/**
 * @memberof module:LocusZoom_DataLayers~highlight_regions
 */
const default_layout = {
    color: '#CCCCCC',
    fill_opacity: 0.5,
    // By default, it will draw the regions shown.
    filters: null,
    // Most use cases will show a preset list of regions defined in the layout
    //  (if empty, AND layout.fields is not, it could fetch from a data source instead)
    regions: [],
    id_field: 'id',
    start_field: 'start',
    end_field: 'end',
    merge_field: null,
};

/**
 * "Highlight regions with rectangle" data layer.
 * Creates one (or more) continuous 2D rectangles that mark an entire interval, to the full height of the panel.
 *
 * Each individual rectangle can be shown in full, or overlapping ones can be merged (eg, based on same category).
 * The rectangles are generally drawn with partial transparency, and do not respond to mouse events: they are a
 *   useful highlight tool to draw attention to intervals that contain interesting variants.
 *
 * This layer has several useful modes:
 * 1. Draw one or more specified rectangles as provided from:
 *      A. Hard-coded layout (layout.regions)
 *      B. Data fetched from a source (like intervals with start and end coordinates)- as specified in layout.fields
 * 2. Fetch data from an external source, and only render the intervals that match criteria
 *
 * @alias module:LocusZoom_DataLayers~highlight_regions
 * @see {@link module:LocusZoom_DataLayers~BaseDataLayer} for additional layout options
 */
class HighlightRegions extends BaseDataLayer {
    /**
     * @param {String|module:LocusZoom_DataLayers~ScalableParameter[]} [layout.color='#CCCCCC'] The fill color for each rectangle
     * @param {String|module:LocusZoom_DataLayers~ScalableParameter[]} [layout.fill_opacity=0.5] The opacity (0-1). We recommend partial transparency so that
     *   rectangles do not hide or interfere with adjacent elements.
     * @param {Object[]} [layout.filters] An array of filter entries specifying which intervals to draw annotations for.
     * @param {Object[]} [layout.regions] A hard-coded list of regions. If provided, takes precedence over data fetched from an external source.
     * @param {String} [layout.start_field='start'] The field to use for rectangle start x coordinate
     * @param {String} [layout.end_field='end'] The field to use for rectangle end x coordinate
     * @param {String} [layout.merge_field] If two intervals overlap, they can be "merged" based on a field that
     *  identifies the category (eg, only rectangles of the same category will be merged).
     *  This field must be present in order to trigger merge behavior. This is applied after filters.
     */
    constructor(layout) {
        merge(layout, default_layout);
        if (layout.interaction || layout.behaviors) {
            throw new Error('highlight_regions layer does not support mouse events');
        }

        if (layout.regions.length && layout.namespace && Object.keys(layout.namespace).length) {
            throw new Error('highlight_regions layer can specify "regions" in layout, OR external data "fields", but not both');
        }
        super(...arguments);
    }

    /**
     * Helper method that combines two rectangles if they are the same type of data (category) and occupy the same
     *  area of the plot (will automatically sort the data prior to rendering)
     *
     * When two fields conflict, it will fill in the fields for the last of the items that overlap in that range.
     *   Thus, it is not recommended to use tooltips with this feature, because the tooltip won't reflect real data.
     * @param {Object[]} data
     * @return {Object[]}
     * @private
     */
    _mergeNodes(data) {
        const { end_field, merge_field, start_field } = this.layout;
        if (!merge_field) {
            return data;
        }

        // Ensure data is sorted by start field, with category as a tie breaker
        data.sort((a, b) => {
            // Ensure that data is sorted by category, then start field (ensures overlapping intervals are adjacent)
            return d3.ascending(a[merge_field], b[merge_field]) || d3.ascending(a[start_field], b[start_field]);
        });

        let track_data = [];
        data.forEach(function (cur_item, index) {
            const prev_item = track_data[track_data.length - 1] || cur_item;
            if (cur_item[merge_field] === prev_item[merge_field] && cur_item[start_field] <= prev_item[end_field]) {
                // If intervals overlap, merge the current item with the previous, and append only the merged interval
                const new_start = Math.min(prev_item[start_field], cur_item[start_field]);
                const new_end = Math.max(prev_item[end_field], cur_item[end_field]);
                cur_item = Object.assign({}, prev_item, cur_item, { [start_field]: new_start, [end_field]: new_end });
                track_data.pop();
            }
            track_data.push(cur_item);
        });
        return track_data;
    }

    render() {
        const { x_scale } = this.parent;
        // Apply filters to only render a specified set of points
        let track_data = this.layout.regions.length ? this.layout.regions : this.data;

        // Pseudo identifier for internal use only (regions have no semantic or transition meaning)
        track_data.forEach((d, i) => d.id || (d.id = i));
        track_data = this._applyFilters(track_data);
        track_data = this._mergeNodes(track_data);

        const selection = this.svg.group.selectAll(`rect.lz-data_layer-${this.layout.type}`)
            .data(track_data);

        // Draw rectangles
        selection.enter()
            .append('rect')
            .attr('class', `lz-data_layer-${this.layout.type}`)
            .merge(selection)
            .attr('id', (d) => this.getElementId(d))
            .attr('x', (d) => x_scale(d[this.layout.start_field]))
            .attr('width', (d) => x_scale(d[this.layout.end_field]) - x_scale(d[this.layout.start_field]))
            .attr('height', this.parent.layout.height)
            .attr('fill', (d, i) => this.resolveScalableParameter(this.layout.color, d, i))
            .attr('fill-opacity', (d, i) => this.resolveScalableParameter(this.layout.fill_opacity, d, i));

        // Remove unused elements
        selection.exit()
            .remove();

        // Note: This layer intentionally does not allow tooltips or mouse behaviors, and doesn't affect pan/zoom
        this.svg.group.style('pointer-events', 'none');
    }

    _getTooltipPosition(tooltip) {
        // This layer is for visual highlighting only; it does not allow mouse interaction, drag, or tooltips
        throw new Error('This layer does not support tooltips');
    }
}

export {HighlightRegions as default};