Source: components/legend.js

/**
 * @module
 * @private
 */
import * as d3 from 'd3';
import {applyStyles} from '../helpers/common';
import {merge, nameToSymbol} from '../helpers/layouts';

// FIXME: Document legend options
/**
 * The default layout used by legends (used internally)
 * @protected
 * @member {Object}
 */
const default_layout = {
    orientation: 'vertical',
    origin: { x: 0, y: 0 },
    width: 10,
    height: 10,
    padding: 5,
    label_size: 14,
    hidden: false,
};

/**
 * An SVG object used to display contextual information about a panel.
 * Panel layouts determine basic features of a legend - its position in the panel, orientation, title, etc.
 * Layouts of child data layers of the panel determine the actual content of the legend.
 *
 * @param {Panel} parent
*/
class Legend {
    constructor(parent) {
        // if (!(parent instanceof LocusZoom.Panel)) {
        //     throw new Error('Unable to create legend, parent must be a locuszoom panel');
        // }
        /** @member {Panel} */
        this.parent = parent;
        /** @member {String} */
        this.id = `${this.parent.getBaseId()}.legend`;

        this.parent.layout.legend = merge(this.parent.layout.legend || {}, default_layout);
        /** @member {Object} */
        this.layout = this.parent.layout.legend;

        /** @member {d3.selection} */
        this.selector = null;
        /** @member {d3.selection} */
        this.background_rect = null;
        /** @member {d3.selection[]} */
        this.elements = [];
        /**
         * SVG selector for the group containing all elements in the legend
         * @protected
         * @member {d3.selection|null}
         */
        this.elements_group = null;

        /**
         * TODO: Not sure if this property is used; the external-facing methods are setting `layout.hidden` instead. Tentatively mark deprecated.
         * @deprecated
         * @protected
         * @member {Boolean}
         */
        this.hidden = false;

        return this.render();
    }

    /**
     * Render the legend in the parent panel
     */
    render() {
        // Get a legend group selector if not yet defined
        if (!this.selector) {
            this.selector = this.parent.svg.group.append('g')
                .attr('id', `${this.parent.getBaseId()}.legend`).attr('class', 'lz-legend');
        }

        // Get a legend background rect selector if not yet defined
        if (!this.background_rect) {
            this.background_rect = this.selector.append('rect')
                .attr('width', 100)
                .attr('height', 100)
                .attr('class', 'lz-legend-background');
        }

        // Get a legend elements group selector if not yet defined
        if (!this.elements_group) {
            this.elements_group = this.selector.append('g');
        }

        // Remove all elements from the document and re-render from scratch
        this.elements.forEach((element) => element.remove());
        this.elements = [];

        // Gather all elements from data layers in order (top to bottom) and render them
        const padding = +this.layout.padding || 1;
        let x = padding;
        let y = padding;
        let line_height = 0;
        this.parent._data_layer_ids_by_z_index.slice().reverse().forEach((id) => {
            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;
                    let label_x = 0;
                    let label_y = (label_size / 2) + (padding / 2);
                    line_height = Math.max(line_height, label_size + padding);
                    // Draw the legend element symbol (line, rect, shape, etc)
                    const shape = element.shape || '';
                    const shape_factory = nameToSymbol(shape);
                    if (shape === 'line') {
                        // Line symbol
                        const length = +element.length || 16;
                        const path_y = (label_size / 4) + (padding / 2);
                        selector
                            .append('path')
                            .attr('class', element.class || '')
                            .attr('d', `M0,${path_y}L${length},${path_y}`)
                            .call(applyStyles, element.style || {});
                        label_x = length + padding;
                    } else if (shape === 'rect') {
                        // Rect symbol
                        const width = +element.width || 16;
                        const height = +element.height || width;
                        selector
                            .append('rect')
                            .attr('class', element.class || '')
                            .attr('width', width)
                            .attr('height', height)
                            .attr('fill', element.color || {})
                            .call(applyStyles, element.style || {});

                        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 horizontally, or vertically, 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 || 25;
                        const height = +element.height || width;
                        const is_horizontal = (element.orientation || 'vertical') === 'horizontal';
                        let color_stops = element.color_stops;

                        const all_elements = selector.append('g');
                        const ribbon_group = all_elements.append('g');
                        const axis_group = all_elements.append('g');
                        let axis_offset = 0;
                        if (element.tick_labels) {
                            let range;
                            if (is_horizontal) {
                                range = [0, width * color_stops.length - 1];  // 1 px offset to align tick with inner borders
                            } else {
                                range = [height * color_stops.length - 1, 0];
                            }
                            const scale = d3.scaleLinear()
                                .domain(d3.extent(element.tick_labels)) // Assumes tick labels are always numeric in this mode
                                .range(range);
                            const axis = (is_horizontal ? d3.axisTop : d3.axisRight)(scale)
                                .tickSize(3)
                                .tickValues(element.tick_labels)
                                .tickFormat((v) => v);
                            axis_group
                                .call(axis)
                                .attr('class', 'lz-axis');
                            let bcr = axis_group.node().getBoundingClientRect();
                            axis_offset = bcr.height;
                        }
                        if (is_horizontal) {
                            // Shift axis down (so that tick marks aren't above the origin)
                            axis_group
                                .attr('transform', `translate(0, ${axis_offset})`);
                            // Ribbon appears below axis
                            ribbon_group
                                .attr('transform', `translate(0, ${axis_offset})`);
                        } else {
                            // Vertical mode: Shift axis ticks to the right of the ribbon
                            all_elements.attr('transform', 'translate(5, 0)');
                            axis_group
                                .attr('transform', `translate(${width}, 0)`);
                        }

                        if (!is_horizontal) {
                            //  Vertical mode: renders top -> bottom but scale is usually specified low..high
                            color_stops = color_stops.slice();
                            color_stops.reverse();
                        }
                        for (let i = 0; i < color_stops.length; i++) {
                            const color = color_stops[i];
                            const to_next_marking = is_horizontal ? `translate(${width * i}, 0)` : `translate(0, ${height * i})`;
                            ribbon_group
                                .append('rect')
                                .attr('class', element.class || '')
                                .attr('stroke', 'black')
                                .attr('transform', to_next_marking)
                                .attr('stroke-width', 0.5)
                                .attr('width', width)
                                .attr('height', height)
                                .attr('fill', color)
                                .call(applyStyles, element.style || {});
                        }

                        // Note: In vertical mode, it's usually easier to put the label above the legend as a separate marker
                        //  This is because the legend element label is drawn last (can't use it's size to position the ribbon, which is drawn first)
                        if (!is_horizontal && element.label) {
                            throw new Error('Legend labels not supported for vertical ribbons (use a separate legend item as text instead)');
                        }
                        // This only makes sense for horizontal labels.
                        label_x = (width * color_stops.length + padding);
                        label_y += axis_offset;
                    } 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;
                        const radius = Math.ceil(Math.sqrt(size / Math.PI));
                        selector
                            .append('path')
                            .attr('class', element.class || '')
                            .attr('d', d3.symbol().size(size).type(shape_factory))
                            .attr('transform', `translate(${radius}, ${radius + (padding / 2)})`)
                            .attr('fill', element.color || {})
                            .call(applyStyles, element.style || {});

                        label_x = (2 * radius) + padding;
                        label_y = Math.max((2 * radius) + (padding / 2), label_y);
                        line_height = Math.max(line_height, (2 * radius) + padding);
                    }
                    // Draw the legend element label
                    selector
                        .append('text')
                        .attr('text-anchor', 'left')
                        .attr('class', 'lz-label')
                        .attr('x', label_x)
                        .attr('y', label_y)
                        .style('font-size', label_size)
                        .text(element.label);

                    // Position the legend element group based on legend layout orientation
                    const bcr = selector.node().getBoundingClientRect();
                    if (this.layout.orientation === 'vertical') {
                        y += bcr.height + padding;
                        line_height = 0;
                    } else {
                        // Ensure this element does not exceed the panel width
                        // (E.g. drop to the next line if it does, but only if it's not the only element on this line)
                        const right_x = this.layout.origin.x + x + bcr.width;
                        if (x > padding && right_x > this.parent.parent.layout.width) {
                            y += line_height;
                            x = padding;
                            selector.attr('transform', `translate(${x}, ${y})`);
                        }
                        x += bcr.width + (3 * padding);
                    }
                    // Store the element
                    this.elements.push(selector);
                });
            }
        });

        // Scale the background rect to the elements in the legend
        const bcr = this.elements_group.node().getBoundingClientRect();
        this.layout.width = bcr.width + (2 * this.layout.padding);
        this.layout.height = bcr.height + (2 * this.layout.padding);
        this.background_rect
            .attr('width', this.layout.width)
            .attr('height', this.layout.height);

        // Set the visibility on the legend from the "hidden" flag
        // TODO: `show()` and `hide()` call a full rerender; might be able to make this more lightweight?
        this.selector
            .style('visibility', this.layout.hidden ? 'hidden' : 'visible');

        return this.position();
    }

    /**
     * Place the legend in position relative to the panel, as specified in the layout configuration
     * @returns {Legend | null}
     * TODO: should this always be chainable?
     */
    position() {
        if (!this.selector) {
            return this;
        }
        const bcr = this.selector.node().getBoundingClientRect();
        if (!isNaN(+this.layout.pad_from_bottom)) {
            this.layout.origin.y = this.parent.layout.height - bcr.height - +this.layout.pad_from_bottom;
        }
        if (!isNaN(+this.layout.pad_from_right)) {
            this.layout.origin.x = this.parent.parent.layout.width - bcr.width - +this.layout.pad_from_right;
        }
        this.selector.attr('transform', `translate(${this.layout.origin.x}, ${this.layout.origin.y})`);
    }

    /**
     * Hide the legend (triggers a re-render)
     * @public
     */
    hide() {
        this.layout.hidden = true;
        this.render();
    }

    /**
     * Show the legend (triggers a re-render)
     * @public
     */
    show() {
        this.layout.hidden = false;
        this.render();
    }
}

export {Legend as default};