# Source: ext/lz-intervals-track.js

    /**
     * Interval annotation track (for chromatin state, etc). Useful for BED file data with non-overlapping intervals.
     *  This is not part of the core LocusZoom library, but can be included as a standalone file.
     *
     * ### Features provided
     * * {@link module:LocusZoom_Adapters~IntervalLZ}
     * * {@link module:LocusZoom_Widgets~toggle_split_tracks}
     * * {@link module:LocusZoom_ScaleFunctions~to_rgb}
     * * {@link module:LocusZoom_DataLayers~intervals}
     * * {@link module:LocusZoom_Layouts~standard_intervals}
     * * {@link module:LocusZoom_Layouts~bed_intervals_layer}
     * * {@link module:LocusZoom_Layouts~intervals_layer}
     * * {@link module:LocusZoom_Layouts~intervals}
     * * {@link module:LocusZoom_Layouts~bed_intervals}
     * * {@link module:LocusZoom_Layouts~interval_association}
     *
     * ### Loading and usage
     * The page must incorporate and load all libraries before this file can be used, including:
     * - LocusZoom
     *
     * To use in an environment without special JS build tooling, simply load the extension file as JS from a CDN (after any dependencies):
     * ```
     * <script src="https://cdn.jsdelivr.net/npm/locuszoom@INSERT_VERSION_HERE/dist/ext/lz-intervals-track.min.js" type="application/javascript"></script>
     * ```
     *
     * To use with ES6 modules, the plugin must be loaded and registered explicitly before use:
     * ```
     * import LocusZoom from 'locuszoom';
     * import IntervalsTrack from 'locuszoom/esm/ext/lz-intervals-track';
     * LocusZoom.use(IntervalsTrack);
     * ```
     *
     * Then use the features made available by this extension. (see demos and documentation for guidance)
     * @module
     */

    import * as d3 from 'd3';

    // Coordinates (start, end) are cached to facilitate rendering
    const XCS = Symbol.for('lzXCS');
    const YCS = Symbol.for('lzYCS');
    const XCE = Symbol.for('lzXCE');
    const YCE = Symbol.for('lzYCE');

    function install (LocusZoom) {
        const BaseUMAdapter = LocusZoom.Adapters.get('BaseUMAdapter');
        const _Button = LocusZoom.Widgets.get('_Button');
        const _BaseWidget = LocusZoom.Widgets.get('BaseWidget');

        /**
         * (**extension**) Retrieve Interval Annotation Data (e.g. BED Tracks), as fetched from the LocusZoom API server (or compatible)
         * @public
         * @alias module:LocusZoom_Adapters~IntervalLZ
         * @see module:LocusZoom_Adapters~BaseUMAdapter
         * @see {@link module:ext/lz-intervals-track} for required extension and installation instructions
         * @param {number} config.params.source The numeric ID for a specific dataset as assigned by the API server
         */
        class IntervalLZ extends BaseUMAdapter {
            _getURL(request_options) {
                const source = this._config.source;
                const query = `?filter=id in ${source} and chromosome eq '${request_options.chr}' and start le ${request_options.end} and end ge ${request_options.start}`;

                const base = super._getURL(request_options);
                return `${base}${query}`;
            }
        }

        /**
         * (**extension**) Button to toggle split tracks mode in an intervals track. This button only works as a panel-level toolbar
         *   and when used with an intervals data layer from this extension.
         * @alias module:LocusZoom_Widgets~toggle_split_tracks
         * @see module:LocusZoom_Widgets~BaseWidget
         * @see {@link module:ext/lz-intervals-track} for required extension and installation instructions
         */
        class ToggleSplitTracks extends _BaseWidget {
            /**
             * @param {string} layout.data_layer_id The ID of the data layer that this button is intended to control.
             */
            constructor(layout) {
                super(...arguments);
                if (!layout.data_layer_id) {
                    layout.data_layer_id = 'intervals';
                }
                if (!this.parent_panel.data_layers[layout.data_layer_id]) {
                    throw new Error('Toggle split tracks widget specifies an invalid data layer ID');
                }
            }

            update() {
                const data_layer = this.parent_panel.data_layers[this.layout.data_layer_id];
                const html = data_layer.layout.split_tracks ? 'Merge Tracks' : 'Split Tracks';
                if (this.button) {
                    this.button.setHtml(html);
                    this.button.show();
                    this.parent.position();
                    return this;
                } else {
                    this.button = new _Button(this)
                        .setColor(this.layout.color)
                        .setHtml(html)
                        .setTitle('Toggle whether tracks are split apart or merged together')
                        .setOnclick(() => {
                            data_layer.toggleSplitTracks();
                            // FIXME: the timeout calls to scale and position (below) cause full ~5 additional re-renders
                            //  If we can remove these it will greatly speed up re-rendering.
                            // The key problem here is that the height is apparently not known in advance and is determined after re-render.
                            if (this.scale_timeout) {
                                clearTimeout(this.scale_timeout);
                            }
                            this.scale_timeout = setTimeout(() => {
                                this.parent_panel.scaleHeightToData();
                                this.parent_plot.positionPanels();
                            }, 0);
                            this.update();
                        });
                    return this.update();
                }
            }
        }

        /**
         * (**extension**) Convert a value ""rr,gg,bb" (if given) to a css-friendly color string: "rgb(rr,gg,bb)".
         * This is tailored specifically to the color specification format embraced by the BED file standard.
         * @alias module:LocusZoom_ScaleFunctions~to_rgb
         * @param {Object} parameters This function has no defined configuration options
         * @param {String|null} value The value to convert to rgb
         * @see {@link module:ext/lz-intervals-track} for required extension and installation instructions
         */
        function to_rgb(parameters, value) {
            return value ? `rgb(${value})` : null;
        }

        const default_layout = {
            start_field: 'start',
            end_field: 'end',
            track_label_field: 'state_name', // Used to label items on the y-axis
            // Used to uniquely identify tracks for coloring. This tends to lead to more stable coloring/sorting
            //  than using the label field- eg, state_ids allow us to set global colors across the entire dataset,
            //  not just choose unique colors within a particular narrow region. (where changing region might lead to more
            //  categories and different colors)
            track_split_field: 'state_id',
            track_split_order: 'DESC',
            track_split_legend_to_y_axis: 2,
            split_tracks: true,
            track_height: 15,
            track_vertical_spacing: 3,
            bounding_box_padding: 2,
            always_hide_legend: false,
            color: '#B8B8B8',
            fill_opacity: 1,
            tooltip_positioning: 'vertical',
        };

        const BaseLayer = LocusZoom.DataLayers.get('BaseDataLayer');

        /**
         * (**extension**) Implements a data layer that will render interval annotation tracks (intervals must provide start and end values)
         * Each interval (such as from a BED file) will be rendered as a rectangle. All spans can be rendered on the same
         *  row, or each (auto-detected) category can be rendered as one row per category.
         *
         * This layer is intended to work with a variety of datasets with special requirements. As such, it has a lot
         *  of configuration options devoted to identifying how to fill in missing information (such as color)
         *
         * @alias module:LocusZoom_DataLayers~intervals
         * @see module:LocusZoom_DataLayers~BaseDataLayer
         * @see {@link module:ext/lz-intervals-track} for required extension and installation instructions
         */
        class LzIntervalsTrack extends BaseLayer {
            /**
             * @param {string} [layout.start_field='start'] The field that defines interval start position
             * @param {string} [layout.end_field='end'] The field that defines interval end position
             * @param {string} [layout.track_label_field='state_name'] Used to label items on the y-axis
             * @param {string} [layout.track_split_field='state_id'] Used to define categories on the y-axis. It is usually most convenient to use
             *  the same value for state_field and label_field (eg 1:1 correspondence).
             * @param {*|'DESC'} [layout.track_split_order='DESC'] When in split tracks mode, should categories be shown in
             *  the order given, or descending order
             * @param {number} [layout.track_split_legend_to_y_axis=2]
             * @param {boolean} [layout.split_tracks=true] Whether to show tracks as merged (one row) or split (many rows)
             *  on initial render.
             * @param {number} [layout.track_height=15] The height of each interval rectangle, in px
             * @param {number} [layout.track_vertical_spacing=3]
             * @param {number} [layout.bounding_box_padding=2]
             * @param {boolean} [layout.always_hide_legend=false] Normally the legend is shown in merged mode and hidden
             *  in split mode. For datasets with a very large number of categories, it may make sense to hide the legend at all times.
             * @param {string|module:LocusZoom_DataLayers~ScalableParameter[]} [layout.color='#B8B8B8'] The color of each datum rectangle
             * @param {number|module:LocusZoom_DataLayers~ScalableParameter[]} [layout.fill_opacity=1]
             * @param {string} [layout.tooltip_positioning='vertical']
             */
            constructor(layout) {
                LocusZoom.Layouts.merge(layout, default_layout);
                super(...arguments);
                this._previous_categories = [];
                this._categories = [];
            }

            initialize() {
                super.initialize();
                this._statusnodes_group = this.svg.group.append('g')
                    .attr('class', 'lz-data-layer-intervals lz-data-layer-intervals-statusnode');
                this._datanodes_group = this.svg.group.append('g')
                    .attr('class', 'lz-data_layer-intervals');
            }

            /**
             * Split data into tracks such that anything with a common grouping field is in the same track
             * @param data
             * @return {unknown[]}
             * @private
             */
            _arrangeTrackSplit(data) {
                const {track_split_field} = this.layout;
                const result = {};
                data.forEach((item) => {
                    const item_key = item[track_split_field];
                    if (!Object.prototype.hasOwnProperty.call(result, item_key)) {
                        result[item_key] = [];
                    }
                    result[item_key].push(item);
                });
                return result;
            }

            /**
             * Split data into rows using a simple greedy algorithm such that no two items overlap (share same interval)
             * Assumes that the data are sorted so item1.start always <= item2.start.
             *
             * This function can also simply return all data on a single row. This functionality may become configurable
             *  in the future but for now reflects a lack of clarity in the requirements/spec. The code to split
             *  overlapping items is present but may not see direct use.
             */
            _arrangeTracksLinear(data, allow_overlap = true) {
                if (allow_overlap) {
                    // If overlap is allowed, then all the data can live on a single row
                    return [data];
                }

                // ASSUMPTION: Data is given to us already sorted by start position to facilitate grouping.
                // We do not sort here because JS "sort" is not stable- if there are many intervals that overlap, then we
                //   can get different layouts (number/order of rows) on each call to "render".
                //
                // At present, we decide how to update the y-axis based on whether current and former number of rows are
                //  the same. An unstable sort leads to layout thrashing/too many re-renders. FIXME: don't rely on counts
                const {start_field, end_field} = this.layout;

                const grouped_data = [[]]; // Prevent two items from colliding by rendering them to different rows, like genes
                data.forEach((item, index) => {
                    for (let i = 0; i < grouped_data.length; i++) {
                        // Iterate over all rows of the
                        const row_to_test = grouped_data[i];
                        const last_item = row_to_test[row_to_test.length - 1];
                        // Some programs report open intervals, eg 0-1,1-2,2-3; these points are not considered to overlap (hence the test isn't "<=")
                        const has_overlap = last_item && (item[start_field] < last_item[end_field]) && (last_item[start_field] < item[end_field]);
                        if (!has_overlap) {
                            // If there is no overlap, add item to current row, and move on to the next item
                            row_to_test.push(item);
                            return;
                        }
                    }
                    // If this item would collide on all existing rows, create a new row
                    grouped_data.push([item]);
                });
                return grouped_data;
            }

            /**
             * Annotate each item with the track number, and return.
             * @param {Object[]}data
             * @private
             * @return [String[], Object[]] Return the categories and the data array
             */
            _assignTracks(data) {
                // Flatten the grouped data.
                const {x_scale} = this.parent;
                const {start_field, end_field, bounding_box_padding, track_height} = this.layout;

                const grouped_data = this.layout.split_tracks ? this._arrangeTrackSplit(data) : this._arrangeTracksLinear(data, true);
                const categories = Object.keys(grouped_data);
                if (this.layout.track_split_order === 'DESC') {
                    categories.reverse();
                }

                categories.forEach((key, row_index) => {
                    const row = grouped_data[key];
                    row.forEach((item) => {
                        item[XCS] = x_scale(item[start_field]);
                        item[XCE] = x_scale(item[end_field]);
                        item[YCS] = row_index * this.getTrackHeight() + bounding_box_padding;
                        item[YCE] = item[YCS] + track_height;
                        // Store the row ID, so that clicking on a point can find the right status node (big highlight box)
                        item.track = row_index;
                    });
                });
                // We're mutating elements of the original data array as a side effect: the return value here is
                //  interchangeable with `this.data` for subsequent usages
                // TODO: Can replace this with array.flat once polyfill support improves
                return [categories, Object.values(grouped_data).reduce((acc, val) => acc.concat(val), [])];
            }

            /**
             * When we are in "split tracks mode", it's convenient to wrap all individual annotations with a shared
             *  highlight box that wraps everything on that row.
             *
             * This is done automatically by the "setElementStatus" code, if this function returns a non-null value
             *
             * To define shared highlighting on the track split field define the status node id override
             * to generate an ID common to the track when we're actively splitting data out to separate tracks
             * @override
             * @returns {String}
             */
            getElementStatusNodeId(element) {
                if (this.layout.split_tracks) {
                    // Data nodes are bound to data objects, but the "status_nodes" selection is bound to numeric row IDs
                    const track = typeof element === 'object' ? element.track : element;
                    const base = `${this.getBaseId()}-statusnode-${track}`;
                    return base.replace(/[^\w]/g, '_');
                }
                // In merged tracks mode, there is no separate status node
                return null;
            }

            // Helper function to sum layout values to derive total height for a single interval track
            getTrackHeight() {
                return this.layout.track_height
                    + this.layout.track_vertical_spacing
                    + (2 * this.layout.bounding_box_padding);
            }

            // Modify the layout as necessary to ensure that appropriate color, label, and legend options are available
            // Even when not displayed, the legend is used to generate the y-axis ticks
            _applyLayoutOptions() {
                const self = this;
                const base_layout = this._base_layout;
                const render_layout = this.layout;
                const base_color_scale = base_layout.color.find(function (item) {
                    return item.scale_function && item.scale_function === 'categorical_bin';
                });
                const color_scale = render_layout.color.find(function (item) {
                    return item.scale_function && item.scale_function === 'categorical_bin';
                });
                if (!base_color_scale) {
                    // This can be a placeholder (empty categories & values), but it needs to be there
                    throw new Error('Interval tracks must define a `categorical_bin` color scale');
                }

                const has_colors = base_color_scale.parameters.categories.length && base_color_scale.parameters.values.length;
                const has_legend = base_layout.legend && base_layout.legend.length;

                if (!!has_colors ^ !!has_legend) {
                    // Don't allow color OR legend to be set manually. It must be both, or neither.
                    throw new Error('To use a manually specified color scheme, both color and legend options must be set.');
                }

                // Harvest any information about an explicit color field that should be considered when generating colors
                const rgb_option = base_layout.color.find(function (item) {
                    return item.scale_function && item.scale_function === 'to_rgb';
                });
                const rgb_field = rgb_option && rgb_option.field;

                // Auto-generate legend based on data
                const known_categories = this._generateCategoriesFromData(this.data, rgb_field); // [id, label, itemRgb] items

                if (!has_colors && !has_legend) {
                    // If no color scheme pre-defined, then make a color scheme that is appropriate and apply to the plot
                    // The legend must match the color scheme. If we generate one, then we must generate both.

                    const colors = this._makeColorScheme(known_categories);
                    color_scale.parameters.categories = known_categories.map(function (item) {
                        return item[0];
                    });
                    color_scale.parameters.values = colors;

                    this.layout.legend = known_categories.map(function (pair, index) {
                        const id = pair[0];
                        const label = pair[1];
                        const item_color = color_scale.parameters.values[index];
                        const item = { shape: 'rect', width: 9, label: label, color: item_color };
                        item[self.layout.track_split_field] = id;
                        return item;
                    });
                }
            }

            // Implement the main render function
            render() {
                //// Autogenerate layout options if not provided
                this._applyLayoutOptions();

                // Determine the appropriate layout for tracks. Store the previous categories (y axis ticks) to decide
                //   whether the axis needs to be re-rendered.
                this._previous_categories = this._categories;
                const [categories, assigned_data] = this._assignTracks(this.data);
                this._categories = categories;
                // Update the legend axis if the number of ticks changed
                const labels_changed = !categories.every( (item, index) => item === this._previous_categories[index]);
                if (labels_changed) {
                    this.updateSplitTrackAxis(categories);
                    return;
                }

                // Apply filters to only render a specified set of points. Hidden fields will still be given space to render, but not shown.
                const track_data = this._applyFilters(assigned_data);

                // Clear before every render so that, eg, highlighting doesn't persist if we load a region with different
                //  categories (row 2 might be a different category and it's confusing if the row stays highlighted but changes meaning)
                // Highlighting will automatically get added back if it actually makes sense, courtesy of setElementStatus,
                //  if a selected item is still in view after the new region loads.
                this._statusnodes_group.selectAll('rect')
                    .remove();

                // Reselect in order to add new data
                const status_nodes = this._statusnodes_group.selectAll('rect')
                    .data(d3.range(categories.length));

                if (this.layout.split_tracks) {
                    // Status nodes: a big highlight box around all items of the same type. Used in split tracks mode,
                    //  because everything on the same row is the same category and a group makes sense
                    // There are no status nodes in merged mode, because the same row contains many kinds of things

                    // Status nodes are 1 per row, so "data" can just be a dummy list of possible row IDs
                    // Each status node is a box that runs the length of the panel and receives a special "colored box" css
                    //  style when selected
                    const height = this.getTrackHeight();
                    status_nodes.enter()
                        .append('rect')
                        .attr('class', 'lz-data_layer-intervals lz-data_layer-intervals-statusnode lz-data_layer-intervals-shared')
                        .attr('rx', this.layout.bounding_box_padding)
                        .attr('ry', this.layout.bounding_box_padding)
                        .merge(status_nodes)
                        .attr('id', (d) => this.getElementStatusNodeId(d))
                        .attr('x', 0)
                        .attr('y', (d) => (d * height))
                        .attr('width', this.parent.layout.cliparea.width)
                        .attr('height', Math.max(height - this.layout.track_vertical_spacing, 1));
                }
                status_nodes.exit()
                    .remove();

                // Draw rectangles for the data (intervals)
                const data_nodes = this._datanodes_group.selectAll('rect')
                    .data(track_data, (d) => d[this.layout.id_field]);

                data_nodes.enter()
                    .append('rect')
                    .merge(data_nodes)
                    .attr('id', (d) => this.getElementId(d))
                    .attr('x', (d) => d[XCS])
                    .attr('y', (d) => d[YCS])
                    .attr('width', (d) => Math.max(d[XCE] - d[XCS], 1))
                    .attr('height', this.layout.track_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));

                data_nodes.exit()
                    .remove();

                this._datanodes_group
                    .call(this.applyBehaviors.bind(this));

                // The intervals track allows legends to be dynamically generated, in which case space can only be
                //  allocated after the panel has been rendered.
                if (this.parent && this.parent.legend) {
                    this.parent.legend.render();
                }
            }

            _getTooltipPosition(tooltip) {
                return {
                    x_min: tooltip.data[XCS],
                    x_max: tooltip.data[XCE],
                    y_min: tooltip.data[YCS],
                    y_max: tooltip.data[YCE],
                };
            }

            // Redraw split track axis or hide it, and show/hide the legend, as determined
            // by current layout parameters and data
            updateSplitTrackAxis(categories) {
                const legend_axis = this.layout.track_split_legend_to_y_axis ? `y${this.layout.track_split_legend_to_y_axis}` : false;
                if (this.layout.split_tracks) {
                    const tracks = +categories.length || 0;
                    const track_height = +this.layout.track_height || 0;
                    const track_spacing = 2 * (+this.layout.bounding_box_padding || 0) + (+this.layout.track_vertical_spacing || 0);
                    const target_height = (tracks * track_height) + ((tracks - 1) * track_spacing);
                    this.parent.scaleHeightToData(target_height);
                    if (legend_axis && this.parent.legend) {
                        this.parent.legend.hide();
                        this.parent.layout.axes[legend_axis] = {
                            render: true,
                            ticks: [],
                            range: {
                                start: (target_height - (this.layout.track_height / 2)),
                                end: (this.layout.track_height / 2),
                            },
                        };
                        // There is a very tight coupling between the display directives: each legend item must identify a key
                        //  field for unique tracks. (Typically this is `state_id`, the same key field used to assign unique colors)
                        // The list of unique keys corresponds to the order along the y-axis
                        this.layout.legend.forEach((element) => {
                            const key = element[this.layout.track_split_field];
                            let track = categories.findIndex((item) => item === key);
                            if (track !== -1) {
                                if (this.layout.track_split_order === 'DESC') {
                                    track = Math.abs(track - tracks - 1);
                                }
                                this.parent.layout.axes[legend_axis].ticks.push({
                                    y: track - 1,
                                    text: element.label,
                                });
                            }
                        });
                        this.layout.y_axis = {
                            axis: this.layout.track_split_legend_to_y_axis,
                            floor: 1,
                            ceiling: tracks,
                        };
                    }
                    // This will trigger a re-render
                    this.parent_plot.positionPanels();
                } else {
                    if (legend_axis && this.parent.legend) {
                        if (!this.layout.always_hide_legend) {
                            this.parent.legend.show();
                        }
                        this.parent.layout.axes[legend_axis] = { render: false };
                        this.parent.render();
                    }
                }
                return this;
            }

            // Method to not only toggle the split tracks boolean but also update
            // necessary display values to animate a complete merge/split
            toggleSplitTracks() {
                this.layout.split_tracks = !this.layout.split_tracks;
                if (this.parent.legend && !this.layout.always_hide_legend) {
                    this.parent.layout.margin.bottom = 5 + (this.layout.split_tracks ? 0 : this.parent.legend.layout.height + 5);
                }
                this.render();
                return this;
            }

            // Choose an appropriate color scheme based on the number of items in the track, and whether or not we are
            //  using explicitly provided itemRgb information
            _makeColorScheme(category_info) {
                // If at least one element has an explicit itemRgb, assume the entire dataset has colors. BED intervals require rgb triplets,so assume that colors will always be "r,g,b" format.
                const has_explicit_colors = category_info.find((item) => item[2]);
                if (has_explicit_colors) {
                    return category_info.map((item) => to_rgb({}, item[2]));
                }

                // Use a set of color schemes for common 15, 18, or 25 state models, as specified from:
                //  https://egg2.wustl.edu/roadmap/web_portal/chr_state_learning.html
                // These are actually reversed so that dim colors come first, on the premise that usually these are the
                //  most common states
                const n_categories = category_info.length;
                if (n_categories <= 15) {
                    return ['rgb(212,212,212)', 'rgb(192,192,192)', 'rgb(128,128,128)', 'rgb(189,183,107)', 'rgb(233,150,122)', 'rgb(205,92,92)', 'rgb(138,145,208)', 'rgb(102,205,170)', 'rgb(255,255,0)', 'rgb(194,225,5)', 'rgb(0,100,0)', 'rgb(0,128,0)', 'rgb(50,205,50)', 'rgb(255,69,0)', 'rgb(255,0,0)'];
                } else if (n_categories <= 18) {
                    return ['rgb(212,212,212)', 'rgb(192,192,192)', 'rgb(128,128,128)', 'rgb(189,183,107)', 'rgb(205,92,92)', 'rgb(138,145,208)', 'rgb(102,205,170)', 'rgb(255,255,0)', 'rgb(255,195,77)', 'rgb(255,195,77)', 'rgb(194,225,5)', 'rgb(194,225,5)', 'rgb(0,100,0)', 'rgb(0,128,0)', 'rgb(255,69,0)', 'rgb(255,69,0)', 'rgb(255,69,0)', 'rgb(255,0,0)'];
                } else {
                    // If there are more than 25 categories, the interval layer will fall back to the 'null value' option
                    return ['rgb(212,212,212)', 'rgb(128,128,128)', 'rgb(112,48,160)', 'rgb(230,184,183)', 'rgb(138,145,208)', 'rgb(102,205,170)', 'rgb(255,255,102)', 'rgb(255,255,0)', 'rgb(255,255,0)', 'rgb(255,255,0)', 'rgb(255,195,77)', 'rgb(255,195,77)', 'rgb(255,195,77)', 'rgb(194,225,5)', 'rgb(194,225,5)', 'rgb(194,225,5)', 'rgb(194,225,5)', 'rgb(0,150,0)', 'rgb(0,128,0)', 'rgb(0,128,0)', 'rgb(0,128,0)', 'rgb(255,69,0)', 'rgb(255,69,0)', 'rgb(255,69,0)', 'rgb(255,0,0)'];
                }
            }

            /**
             * Find all of the unique tracks (a combination of name and ID information)
             * @param {Object} data
             * @param {String} [rgb_field] A field that contains an RGB value. Aimed at BED files with an itemRgb column
             * @private
             * @returns {Array} All [unique_id, label, color] pairs in data. The unique_id is the thing used to define groupings
             *  most unambiguously.
             */
            _generateCategoriesFromData(data, rgb_field) {
                const self = this;
                // Use the hard-coded legend if available (ignoring any mods on re-render)
                const legend = this._base_layout.legend;
                if (legend && legend.length) {
                    return legend.map((item) => [item[this.layout.track_split_field], item.label, item.color]);
                }

                // Generate options from data, if no preset legend exists
                const unique_ids = {}; // make categories unique
                const categories = [];

                data.forEach((item) => {
                    const id = item[self.layout.track_split_field];
                    if (!Object.prototype.hasOwnProperty.call(unique_ids, id)) {
                        unique_ids[id] = null;
                        // If rgbfield is null, then the last entry is undefined/null as well
                        categories.push([id, item[this.layout.track_label_field], item[rgb_field]]);
                    }
                });
                return categories;
            }
        }

        /**
         * (**extension**) A basic tooltip with information to be shown over an intervals datum
         * @alias module:LocusZoom_Layouts~standard_intervals
         * @type tooltip
         * @see {@link module:ext/lz-intervals-track} for required extension and installation instructions
         */
        const intervals_tooltip_layout = {
            closable: false,
            show: { or: ['highlighted', 'selected'] },
            hide: { and: ['unhighlighted', 'unselected'] },
            html: '{{intervals:state_name|htmlescape}}<br>{{intervals:start|htmlescape}}-{{intervals:end|htmlescape}}',
        };

        /**
         * (**extension**) A data layer with some preconfigured options for intervals display. This example was designed for chromHMM output,
         *   in which various states are assigned numeric state IDs and (<= as many) text state names.
         *
         *  This layout is deprecated; most usages would be better served by the bed_intervals_layer layout instead.
         * @alias module:LocusZoom_Layouts~intervals_layer
         * @type data_layer
         * @see {@link module:ext/lz-intervals-track} for required extension and installation instructions
         */
        const intervals_layer_layout =  {
            namespace: { 'intervals': 'intervals' },
            id: 'intervals',
            type: 'intervals',
            tag: 'intervals',
            id_field: '{{intervals:start}}_{{intervals:end}}_{{intervals:state_name}}',
            start_field: 'intervals:start',
            end_field: 'intervals:end',
            track_split_field: 'intervals:state_name',
            track_label_field: 'intervals:state_name',
            split_tracks: false,
            always_hide_legend: true,
            color: [
                {
                    // If present, an explicit color field will override any other option (and be used to auto-generate legend)
                    field: 'intervals:itemRgb',
                    scale_function: 'to_rgb',
                },
                {
                    // TODO: Consider changing this to stable_choice in the future, for more stable coloring
                    field: 'intervals:state_name',
                    scale_function: 'categorical_bin',
                    parameters: {
                        // Placeholder. Empty categories and values will automatically be filled in when new data loads.
                        categories: [],
                        values: [],
                        null_value: '#B8B8B8',
                    },
                },
            ],
            legend: [], // Placeholder; auto-filled when data loads.
            behaviors: {
                onmouseover: [
                    { action: 'set', status: 'highlighted' },
                ],
                onmouseout: [
                    { action: 'unset', status: 'highlighted' },
                ],
                onclick: [
                    { action: 'toggle', status: 'selected', exclusive: true },
                ],
                onshiftclick: [
                    { action: 'toggle', status: 'selected' },
                ],
            },
            tooltip: intervals_tooltip_layout,
        };

        /**
         * (**extension**) A data layer with some preconfigured options for intervals display. This example was designed for standard BED3+ files and the field names emitted by the LzParsers extension.
         * @alias module:LocusZoom_Layouts~bed_intervals_layer
         * @type data_layer
         * @see {@link module:ext/lz-intervals-track} for required extension and installation instructions
         */
        const bed_intervals_layer_layout = LocusZoom.Layouts.merge({
            id_field: '{{intervals:chromStart}}_{{intervals:chromEnd}}_{{intervals:name}}',
            start_field: 'intervals:chromStart',
            end_field: 'intervals:chromEnd',
            track_split_field: 'intervals:name',
            track_label_field: 'intervals:name',
            split_tracks: true,
            always_hide_legend: false,
            color: [
                {
                    // If present, an explicit color field will override any other option (and be used to auto-generate legend)
                    field: 'intervals:itemRgb',
                    scale_function: 'to_rgb',
                },
                {
                    // TODO: Consider changing this to stable_choice in the future, for more stable coloring
                    field: 'intervals:name',
                    scale_function: 'categorical_bin',
                    parameters: {
                        // Placeholder. Empty categories and values will automatically be filled in when new data loads.
                        categories: [],
                        values: [],
                        null_value: '#B8B8B8',
                    },
                },
            ],
            tooltip: LocusZoom.Layouts.merge({
                html: `<strong>Group: </strong>{{intervals:name|htmlescape}}<br>
    <strong>Region: </strong>{{intervals:chromStart|htmlescape}}-{{intervals:chromEnd|htmlescape}}
    {{#if intervals:score}}<br>
    <strong>Score:</strong> {{intervals:score|htmlescape}}{{/if}}`,
            }, intervals_tooltip_layout),
        }, intervals_layer_layout);

        /**
         * (**extension**) A panel containing an intervals data layer, eg for BED tracks. This is a legacy layout whose field names were specific to one partner site.
         * @alias module:LocusZoom_Layouts~intervals
         * @type panel
         * @see {@link module:ext/lz-intervals-track} for required extension and installation instructions
         */
        const intervals_panel_layout = {
            id: 'intervals',
            tag: 'intervals',
            min_height: 50,
            height: 50,
            margin: { top: 25, right: 150, bottom: 5, left: 70 },
            toolbar: (function () {
                const l = LocusZoom.Layouts.get('toolbar', 'standard_panel');
                l.widgets.push({
                    type: 'toggle_split_tracks',
                    data_layer_id: 'intervals',
                    position: 'right',
                });
                return l;
            })(),
            axes: {},
            interaction: {
                drag_background_to_pan: true,
                scroll_to_zoom: true,
                x_linked: true,
            },
            legend: {
                hidden: true,
                orientation: 'horizontal',
                origin: { x: 50, y: 0 },
                pad_from_bottom: 5,
            },
            data_layers: [intervals_layer_layout],
        };

        /**
         * (**extension**) A panel containing an intervals data layer, eg for BED tracks. These field names match those returned by the LzParsers extension.
         * @alias module:LocusZoom_Layouts~bed_intervals
         * @type panel
         * @see {@link module:ext/lz-intervals-track} for required extension and installation instructions
         */
        const bed_intervals_panel_layout = LocusZoom.Layouts.merge({
            // Normal BED tracks show the panel legend in collapsed mode!
            min_height: 120,
            height: 120,
            data_layers: [bed_intervals_layer_layout],
        }, intervals_panel_layout);

        /**
         * (**extension**) A plot layout that shows association summary statistics, genes, and interval data. This example assumes
         *  chromHMM data. (see panel layout) Few people will use the full intervals plot layout directly outside of an example.
         * @alias module:LocusZoom_Layouts~interval_association
         * @type plot
         * @see {@link module:ext/lz-intervals-track} for required extension and installation instructions
         */
        const intervals_plot_layout = {
            state: {},
            width: 800,
            responsive_resize: true,
            min_region_scale: 20000,
            max_region_scale: 1000000,
            toolbar: LocusZoom.Layouts.get('toolbar', 'standard_association'),
            panels: [
                LocusZoom.Layouts.get('panel', 'association'),
                LocusZoom.Layouts.merge({ min_height: 120, height: 120 }, intervals_panel_layout),
                LocusZoom.Layouts.get('panel', 'genes'),
            ],
        };

        LocusZoom.Adapters.add('IntervalLZ', IntervalLZ);
        LocusZoom.DataLayers.add('intervals', LzIntervalsTrack);

        LocusZoom.Layouts.add('tooltip', 'standard_intervals', intervals_tooltip_layout);
        LocusZoom.Layouts.add('data_layer', 'intervals', intervals_layer_layout);
        LocusZoom.Layouts.add('data_layer', 'bed_intervals', bed_intervals_layer_layout);
        LocusZoom.Layouts.add('panel', 'intervals', intervals_panel_layout);
        LocusZoom.Layouts.add('panel', 'bed_intervals', bed_intervals_panel_layout);
        LocusZoom.Layouts.add('plot', 'interval_association', intervals_plot_layout);

        LocusZoom.ScaleFunctions.add('to_rgb', to_rgb);

        LocusZoom.Widgets.add('toggle_split_tracks', ToggleSplitTracks);
    }

    if (typeof LocusZoom !== 'undefined') {
        // Auto-register the plugin when included as a script tag. ES6 module users must register via LocusZoom.use()
        // eslint-disable-next-line no-undef
        LocusZoom.use(install);
    }

    export default install;