Source: components/data_layer/line.js

import * as d3 from 'd3';

import BaseDataLayer from './base';
import {merge} from '../../helpers/layouts';
import {STATUSES} from '../constants';
import {applyStyles} from '../../helpers/common';

/**
 * @memberof module:LocusZoom_DataLayers~line
 */
const default_layout = {
    style: {
        fill: 'none',
        'stroke-width': '2px',
    },
    interpolate: 'curveLinear',
    x_axis: { field: 'x' },
    y_axis: { field: 'y', axis: 1 },
    hitarea_width: 5,
    tooltip: null,
};

/*********************
 * Line Data Layer
 * Implements a standard line plot, representing either a trace or a filled curve. Only one line is drawn per layer used.
 * @alias module:LocusZoom_DataLayers~line
 * @see {@link module:LocusZoom_DataLayers~BaseDataLayer} for additional layout options
*/
class Line extends BaseDataLayer {
    /**
     * @param {object} [layout.style] CSS properties to control how the line is drawn
     * @param {string} [layout.style.fill='none'] Fill color for the area under the curve
     * @param {string} [layout.style.stroke]
     * @param {string} [layout.style.stroke-width='2px']
     * @param {string} [layout.interpolate='curveLinear'] The name of the d3 interpolator to use. This determines how to smooth the line in between data points.
     * @param {number} [layout.hitarea_width=5] The size of mouse event hitareas to use. If tooltips are not used, hitareas are not very important.
     */
    constructor(layout) {
        layout = merge(layout, default_layout);
        if (layout.tooltip) {
            throw new Error('The line / filled curve layer does not support tooltips');
        }
        super(...arguments);
    }

    /**
     * Implement the main render function
     */
    render() {
        // Several vars needed to be in scope
        const panel = this.parent;
        const x_field = this.layout.x_axis.field;
        const y_field = this.layout.y_axis.field;

        // Join data to the line selection
        const selection = this.svg.group
            .selectAll('path.lz-data_layer-line')
            .data([this.data]);

        // Create path element, apply class
        this.path = selection.enter()
            .append('path')
            .attr('class', 'lz-data_layer-line');

        // Generate the line
        let line;
        const x_scale = panel['x_scale'];
        const y_scale = panel[`y${this.layout.y_axis.axis}_scale`];
        if (this.layout.style.fill && this.layout.style.fill !== 'none') {
            // Filled curve: define the line as a filled boundary
            line = d3.area()
                .x((d) => +x_scale(d[x_field]))
                .y0(+y_scale(0))
                .y1((d) => +y_scale(d[y_field]));
        } else {
            // Basic line
            line = d3.line()
                .x((d) => +x_scale(d[x_field]))
                .y((d) => +y_scale(d[y_field]))
                .curve(d3[this.layout.interpolate]);
        }

        // Apply line and style
        selection.merge(this.path)
            .attr('d', line)
            .call(applyStyles, this.layout.style);

        // Remove old elements as needed
        selection.exit()
            .remove();

    }

    /**
     * Redefine setElementStatus family of methods as line data layers will only ever have a single path element
     * @param {String} status A member of `LocusZoom.DataLayer.Statuses.adjectives`
     * @param {String|Object} element
     * @param {Boolean} toggle
     */
    setElementStatus(status, element, toggle) {
        return this.setAllElementStatus(status, toggle);
    }

    setAllElementStatus(status, toggle) {
        // Sanity check
        if (typeof status == 'undefined' || !STATUSES.adjectives.includes(status)) {
            throw new Error('Invalid status');
        }
        if (typeof this._layer_state.status_flags[status] == 'undefined') {
            return this;
        }
        if (typeof toggle == 'undefined') {
            toggle = true;
        }

        // Update global status flag
        this._global_statuses[status] = toggle;

        // Apply class to path based on global status flags
        let path_class = 'lz-data_layer-line';
        Object.keys(this._global_statuses).forEach((global_status) => {
            if (this._global_statuses[global_status]) {
                path_class += ` lz-data_layer-line-${global_status}`;
            }
        });
        this.path.attr('class', path_class);

        // Trigger layout changed event hook
        this.parent.emit('layout_changed', true);
        return this;
    }
}

/**
 * @memberof module:LocusZoom_DataLayers~orthogonal_line
 */
const default_orthogonal_layout = {
    style: {
        'stroke': '#D3D3D3',
        'stroke-width': '3px',
        'stroke-dasharray': '10px 10px',
    },
    orientation: 'horizontal',
    x_axis: {
        axis: 1,
        decoupled: true,
    },
    y_axis: {
        axis: 1,
        decoupled: true,
    },
    tooltip_positioning: 'vertical',
    offset: 0,
};


/**
 *  Orthogonal Line Data Layer
 *  Draw a horizontal or vertical line given an orientation and an offset in the layout
 *  Does not require a data source or fields.
 * @alias module:LocusZoom_DataLayers~orthogonal_line
 * @see {@link module:LocusZoom_DataLayers~BaseDataLayer} for additional layout options
 */
class OrthogonalLine extends BaseDataLayer {
    /**
     * @param {string} [layout.style.stroke='#D3D3D3']
     * @param {string} [layout.style.stroke-width='3px']
     * @param {string} [layout.style.stroke-dasharray='10px 10px']
     * @param {'horizontal'|'vertical'} [layout.orientation] The orientation of the horizontal line
     * @param {boolean} [layout.x_axis.decoupled=true] If true, the data in this layer will not influence the x-extent of the panel.
     * @param {boolean} [layout.y_axis.decoupled=true] If true, the data in this layer will not influence the y-extent of the panel.
     * @param {'horizontal'|'vertical'} [layout.tooltip_positioning='vertical'] Where to draw the tooltip relative to the mouse pointer.
     * @param {number} [layout.offset=0] Where the line intercepts the orthogonal axis (eg, the y coordinate for a horizontal line, or x for a vertical line)
     */
    constructor(layout) {
        layout = merge(layout, default_orthogonal_layout);
        // Require that orientation be "horizontal" or "vertical" only
        if (!['horizontal', 'vertical'].includes(layout.orientation)) {
            layout.orientation = 'horizontal';
        }
        super(...arguments);
    }

    getElementId(element) {
        // There is only one line per datalayer, so this is sufficient.
        return this.getBaseId();
    }

    /**
     * Implement the main render function
     */
    render() {
        // Several vars needed to be in scope
        const panel = this.parent;
        const x_scale = 'x_scale';
        const y_scale = `y${this.layout.y_axis.axis}_scale`;
        const x_extent = 'x_extent';
        const y_extent = `y${this.layout.y_axis.axis}_extent`;
        const x_range = 'x_range';

        // Generate data using extents depending on orientation
        if (this.layout.orientation === 'horizontal') {
            this.data = [
                { x: panel[x_extent][0], y: this.layout.offset },
                { x: panel[x_extent][1], y: this.layout.offset },
            ];
        } else if (this.layout.orientation === 'vertical') {
            this.data = [
                { x: this.layout.offset, y: panel[y_extent][0] },
                { x: this.layout.offset, y: panel[y_extent][1] },
            ];
        } else {
            throw new Error('Unrecognized vertical line type. Must be "vertical" or "horizontal"');
        }

        // Join data to the line selection
        const selection = this.svg.group
            .selectAll('path.lz-data_layer-line')
            .data([this.data]);

        // In some cases, a vertical line may overlay a track that has no inherent y-values (extent)
        //  When that happens, provide a default height based on the current panel dimensions (accounting
        //      for any resizing that happened after the panel was created)
        const default_y = [panel.layout.cliparea.height, 0];

        // Generate the line
        const line = d3.line()
            .x((d, i) => {
                const x = +panel[x_scale](d['x']);
                return isNaN(x) ? panel[x_range][i] : x;
            })
            .y((d, i) => {
                const y = +panel[y_scale](d['y']);
                return isNaN(y) ? default_y[i] : y;
            });

        // Create path element, apply class
        this.path = selection.enter()
            .append('path')
            .attr('class', 'lz-data_layer-line')
            .merge(selection)
            .attr('d', line)
            .call(applyStyles, this.layout.style)
            // Allow the layer to respond to mouseover events and show a tooltip.
            .call(this.applyBehaviors.bind(this));

        // Remove old elements as needed
        selection.exit()
            .remove();
    }

    _getTooltipPosition(tooltip) {
        try {
            const coords = d3.mouse(this.svg.container.node());
            const x = coords[0];
            const y = coords[1];
            return { x_min: x - 1, x_max: x + 1, y_min: y - 1, y_max: y + 1 };
        } catch (e) {
            // On redraw, there won't be a mouse event, so skip tooltip repositioning.
            return null;
        }
    }

}


export { Line as line, OrthogonalLine as orthogonal_line };