# Source: components/panel.js

    import * as d3 from 'd3';

    import {STATUSES} from './constants';
    import Toolbar from './toolbar';
    import {applyStyles, generateCurtain, generateLoader} from '../helpers/common';
    import {parseFields, positionIntToString, prettyTicks} from '../helpers/display';
    import {merge} from '../helpers/layouts';
    import Legend from './legend';
    import data_layers from '../registry/data_layers';

    /**
     * Default panel layout
     * @memberof Panel
     * @static
     * @type {Object}
     */
    const default_layout = {
        id: '',
        tag: 'custom_data_type',
        title: { text: '', style: {}, x: 10, y: 22 },
        y_index: null,
        min_height: 1,
        height: 1,
        origin: { x: 0, y: null },
        margin: { top: 0, right: 0, bottom: 0, left: 0 },
        background_click: 'clear_selections',
        toolbar: {
            widgets: [],
        },
        cliparea: {
            height: 0,
            width: 0,
            origin: { x: 0, y: 0 },
        },
        axes: {  // These are the only axes supported!!
            x:  {},
            y1: {},
            y2: {},
        },
        legend: null,
        interaction: {
            drag_background_to_pan: false,
            drag_x_ticks_to_scale: false,
            drag_y1_ticks_to_scale: false,
            drag_y2_ticks_to_scale: false,
            scroll_to_zoom: false,
            x_linked: false,
            y1_linked: false,
            y2_linked: false,
        },
        show_loading_indicator: true,
        data_layers: [],
    };

    /**
     * A panel is an abstract class representing a subdivision of the LocusZoom stage
     *   to display a distinct data representation as a collection of data layers.
     */
    class Panel {
        /**
         * @param {string} layout.id An identifier string that must be unique across all panels in the plot. Required.
         * @param {string} [layout.tag='custom_data_type'] Tags have no functional purpose, but they can be used
         *   as a semantic label for what is being displayed in this element. This makes it easy to write custom code like "find every panel
         *   that shows association scatter plots, anywhere": even if the IDs are different, the tag can be the same.
         *   Most built-in panels will contain a tag that describes, in human-readable terms, what kind of data is being shown.
         *   (see: {@link LayoutRegistry.mutate_attrs})
         * @param {boolean} [layout.show_loading_indicator=true] Whether to show a "loading indicator" while data is being fetched
         * @param {module:LocusZoom_DataLayers[]} [layout.data_layers] Data layer layout objects
         * @param {module:LocusZoom_Widgets[]} [layout.toolbar.widgets] Configuration options for each toolbar widget; {@link module:LocusZoom_Widgets}
         * @param {number} [layout.title.text] Text to show in panel title
         * @param {number} [layout.title.style] CSS options to apply to the title
         * @param {number} [layout.title.x=10] x-offset for title position
         * @param {number} [layout.title.y=22] y-offset for title position
         * @param {'vertical'|'horizontal'} [layout.legend.orientation='vertical']  Orientation with which elements in the legend should be arranged.
         *   Presently only "vertical" and "horizontal" are supported values. When using the horizontal orientation
         *   elements will automatically drop to a new line if the width of the legend would exceed the right edge of the
         *   containing panel. Defaults to "vertical".
         * @param {number} [layout.legend.origin.x=0]  X-offset, in pixels, for the top-left corner of the legend (relative to the top left corner of the panel).
         * @param {number} [layout.legend.origin.y=0] Y-offset, in pixels, for the top-left corner of the legend (relative to the top left corner of the panel).
         *   NOTE: SVG y values go from the top down, so the SVG origin of (0,0) is in the top left corner.
         * @param {number} [layout.legend.padding=5]  Value in pixels to pad between the legend's outer border and the
         *   elements within the legend. This value is also used for spacing between elements in the legend on different
         *   lines (e.g. in a vertical orientation) and spacing between element shapes and labels, as well as between
         *   elements in a horizontal orientation, are defined as a function of this value. Defaults to 5.
         * @param {number} [layout.legend.label_size=12]  Font size for element labels in the legend (loosely analogous to the height of full-height letters, in pixels). Defaults to 12.
         * @param {boolean} [layout.legend.hidden=false] Whether to hide the legend by default
         * @param {number} [layout.y_index] The position of the panel (above or below other panels). This is usually set
         *  automatically when the panel is added, and rarely controlled directly.
         * @param {number} [layout.min_height=1] When resizing, do not allow height to go below this value
         * @param {number} [layout.height=1] The actual height allocated to the panel (>= min_height)
         * @param {number} [layout.margin.top=0] The margin (space between top of panel and edge of viewing area)
         * @param {number} [layout.margin.right=0] The margin (space between right side of panel and edge of viewing area)
         * @param {number} [layout.margin.bottom=0] The margin (space between bottom of panel and edge of viewing area)
         * @param {number} [layout.margin.left=0] The margin (space between left side of panel and edge of viewing area)
         * @param {'clear_selections'|null} [layout.background_click='clear_selections'] What happens when the background of the panel is clicked
         * @param {'state'|null} [layout.axes.x.extent] If 'state', the x extent will be determined from plot.state (a
         *   shared region). Otherwise it will be determined based on data later ranges.
         * @param {string} [layout.axes.x.label] Label text for the provided axis
         * @param {number} [layout.axes.x.label_offset]
         * @param {boolean} [layout.axes.x.render] Whether to render this axis
         * @param {'region'|null} [layout.axes.x.tick_format] If 'region', format ticks in a concise way suitable for
         *   genomic coordinates, eg 23423456 => 23.42 (Mb)
         * @param {Array} [layout.axes.x.ticks] An array of custom ticks that will override any automatically generated)
         * @param {string} [layout.axes.y1.label] Label text for the provided axis
         * @param {number} [layout.axes.y1.label_offset] The distance between the axis title and the axis. Use this to prevent
         *  the title from overlapping with tick mark labels. If there is not enough space for the label, be sure to increase the panel margins (left or right) accordingly.
         * @param {boolean} [layout.axes.y1.render=false] Whether to render this axis
         * @param {Array} [layout.axes.y1.ticks] An array of custom ticks that will override any automatically generated)
         * @param {string} [layout.axes.y2.label] Label text for the provided axis
         * @param {number} [layout.axes.y2.label_offset]
         * @param {boolean} [layout.axes.y2.render=false] Whether to render this axis
         * @param {Array} [layout.axes.y2.ticks] An array of custom ticks that will override any automatically generated)
         * @param {boolean} [layout.interaction.drag_background_to_pan=false] Allow the user to drag the panel background to pan
         *   the plot to another genomic region.
         * @param {boolean} [layout.interaction.drag_x_ticks_to_scale=false] Allow the user to rescale the x axis by dragging x ticks
         * @param {boolean}  [layout.interaction.drag_y1_ticks_to_scale=false] Allow the user to rescale the y1 axis by dragging y1 ticks
         * @param {boolean} [layout.interaction.drag_y2_ticks_to_scale=false] Allow the user to rescale the y2 axis by dragging y2 ticks
         * @param {boolean} [layout.interaction.scroll_to_zoom=false] Allow the user to rescale the plot by mousewheel-scrolling
         * @param {boolean} [layout.interaction.x_linked=false] Whether this panel should change regions to match all other linked panels
         * @param {boolean} [layout.interaction.y1_linked=false] Whether this panel should rescale to match all other linked panels
         * @param {boolean} [layout.interaction.y2_linked=false] Whether this panel should rescale to match all other linked panels
         * @param {Plot|null} parent
         */
        constructor(layout, parent) {
            if (typeof layout !== 'object') {
                throw new Error('Unable to create panel, invalid layout');
            }

            /**
             * @protected
             * @member {Plot|null}
             */
            this.parent = parent || null;
            /**
             *  @protected
             *  @member {Plot|null}
             */
            this.parent_plot = parent;

            if (typeof layout.id !== 'string' || !layout.id) {
                throw new Error('Panel layouts must specify "id"');
            } else if (this.parent) {
                if (typeof this.parent.panels[layout.id] !== 'undefined') {
                    throw new Error(`Cannot create panel with id [${layout.id}]; panel with that id already exists`);
                }
            }
            /**
             * @public
             * @member {String}
             */
            this.id = layout.id;

            /**
             * @private
             * @member {Boolean}
             */
            this._initialized = false;
            /**
             * The index of this panel in the parent plot's `layout.panels`
             * @private
             * @member {number}
             * */
            this._layout_idx = null;
            /**
             * @private
             * @member {Object}
             */
            this.svg = {};

            /**
             * A JSON-serializable object used to describe the composition of the Panel
             * @public
             * @member {Object}
             */
            this.layout = merge(layout || {}, default_layout);

            // Define state parameters specific to this panel
            if (this.parent) {
                /**
                 * @private
                 * @member {Object}
                 */
                this.state = this.parent.state;

                /**
                 *  @private
                 *  @member {String}
                 */
                this._state_id = this.id;
                this.state[this._state_id] = this.state[this._state_id] || {};
            } else {
                this.state = null;
                this._state_id = null;
            }

            /**
             * Direct access to data layer instances, keyed by data layer ID. Used primarily for introspection/ development.
             * @public
             * @member {Object.<String, BaseDataLayer>}
             */
            this.data_layers = {};
            /**
             * @private
             * @member {String[]}
             */
            this._data_layer_ids_by_z_index = [];

            /**
             * Track data requests in progress
             * @member {Promise[]}
             * @private
             */
            this._data_promises = [];

            /**
             * @private
             * @member {d3.scale}
             */
            this.x_scale  = null;
            /**
             * @private
             * @member {d3.scale}
             */
            this.y1_scale = null;
            /**
             *  @private
             *  @member {d3.scale}
             */
            this.y2_scale = null;

            /**
             * @private
             * @member {d3.extent}
             */
            this.x_extent  = null;
            /**
             *  @private
             *  @member {d3.extent}
             */
            this.y1_extent = null;
            /**
             *  @private
             *  @member {d3.extent}
             */
            this.y2_extent = null;

            /**
             * @private
             * @member {Number[]}
             */
            this.x_ticks  = [];
            /**
             *  @private
             *  @member {Number[]}
             */
            this.y1_ticks = [];
            /**
             * @private
             * @member {Number[]}
             */
            this.y2_ticks = [];

            /**
             * A timeout ID as returned by setTimeout
             * @private
             * @member {number}
             */
            this._zoom_timeout = null;

            /**
             * Known event hooks that the panel can respond to
             * @see {@link event:any_lz_event} for a list of pre-defined events commonly used by LocusZoom
             * @protected
             * @member {Object}
             */
            this._event_hooks = {};

            // Initialize the layout
            this.initializeLayout();
        }

        /******* Public methods: intended for direct external manipulation of panel internals */

        /**
         * There are several events that a LocusZoom panel can "emit" when appropriate, and LocusZoom supports registering
         *   "hooks" for these events which are essentially custom functions intended to fire at certain times.
         *
         * To register a hook for any of these events use `panel.on('event_name', function() {})`.
         *
         * There can be arbitrarily many functions registered to the same event. They will be executed in the order they
         *   were registered.
         *
         * @public
         * @see {@link event:any_lz_event} for a list of pre-defined events commonly used by LocusZoom
         * @param {String} event The name of the event. Consult documentation for the names of built-in events.
         * @param {function} hook
         * @returns {function} The registered event listener
         */
        on(event, hook) {
            // TODO: Dry plot and panel event code into a shared mixin
            if (typeof event !== 'string') {
                throw new Error(`Unable to register event hook. Event name must be a string: ${event.toString()}`);
            }
            if (typeof hook != 'function') {
                throw new Error('Unable to register event hook, invalid hook function passed');
            }
            if (!this._event_hooks[event]) {
                // We do not validate on known event names, because LZ is allowed to track and emit custom events like "widget button clicked".
                this._event_hooks[event] = [];
            }
            this._event_hooks[event].push(hook);
            return hook;
        }

        /**
         * Remove one or more previously defined event listeners
         * @public
         * @param {String} event The name of an event (as defined in `event_hooks`)
         * @param {eventCallback} [hook] The callback to deregister
         * @returns {Panel}
         */
        off(event, hook) {
            const theseHooks = this._event_hooks[event];
            if (typeof event != 'string' || !Array.isArray(theseHooks)) {
                throw new Error(`Unable to remove event hook, invalid event: ${event.toString()}`);
            }
            if (hook === undefined) {
                // Deregistering all hooks for this event may break basic functionality, and should only be used during
                //  cleanup operations (eg to prevent memory leaks)
                this._event_hooks[event] = [];
            } else {
                const hookMatch = theseHooks.indexOf(hook);
                if (hookMatch !== -1) {
                    theseHooks.splice(hookMatch, 1);
                } else {
                    throw new Error('The specified event listener is not registered and therefore cannot be removed');
                }
            }
            return this;
        }

        /**
         * Handle running of event hooks when an event is emitted
         *
         * There is a shorter overloaded form of this method: if the event does not have any data, the second
         *   argument can be a boolean to control bubbling
         *
         * @public
         * @see {@link event:any_lz_event} for a list of pre-defined events commonly used by LocusZoom
         * @param {string} event A known event name
         * @param {*} [eventData] Data or event description that will be passed to the event listener
         * @param {boolean} [bubble=false] Whether to bubble the event to the parent
         * @returns {Panel}
         */
        emit(event, eventData, bubble)  {
            bubble = bubble || false;

            // TODO: DRY this with the parent plot implementation. Ensure interfaces remain compatible.
            // TODO: Improve documentation for overloaded method signature (JSDoc may have trouble here)
            if (typeof event != 'string') {
                throw new Error(`LocusZoom attempted to throw an invalid event: ${event.toString()}`);
            }
            if (typeof eventData === 'boolean' && arguments.length === 2) {
                // Overloaded method signature: emit(event, bubble)
                bubble = eventData;
                eventData = null;
            }
            const sourceID = this.getBaseId();
            const eventContext = { sourceID: sourceID, target: this, data: eventData || null };

            if (this._event_hooks[event]) {
                // If the tree_fall event is emitted in a forest and no one is around to hear it, does it really make a sound?
                this._event_hooks[event].forEach((hookToRun) => {
                    // By default, any handlers fired here will see the panel as the value of `this`. If a bound function is
                    // registered as a handler, the previously bound `this` will override anything provided to `call` below.
                    hookToRun.call(this, eventContext);
                });
            }

            if (bubble && this.parent) {
                // Even if this event has no listeners locally, it might still have listeners on the parent
                this.parent.emit(event, eventContext);
            }
            return this;
        }

        /**
         * Set the title for the panel. If passed an object, will merge the object with the existing layout configuration, so
         *   that all or only some of the title layout object's parameters can be customized. If passed null, false, or an empty
         *   string, the title DOM element will be set to display: none.
         *
         * @public
         * @param {string|object|null} title The title text, or an object with additional configuration
         * @param {string} title.text Text to display. Since titles are rendered as SVG text, HTML and newlines will not be rendered.
         * @param {number} title.x X-offset, in pixels, for the title's text anchor (default left) relative to the top-left corner of the panel.
         * @param {number} title.y Y-offset, in pixels, for the title's text anchor (default left) relative to the top-left corner of the panel.
            NOTE: SVG y values go from the top down, so the SVG origin of (0,0) is in the top left corner.
         * @param {object} title.style CSS styles object to be applied to the title's DOM element.
         * @returns {Panel}
         */
        setTitle(title) {
            if (typeof this.layout.title == 'string') {
                const text = this.layout.title;
                this.layout.title = { text: text, x: 0, y: 0, style: {} };
            }
            if (typeof title == 'string') {
                this.layout.title.text = title;
            } else if (typeof title == 'object' && title !== null) {
                this.layout.title = merge(title, this.layout.title);
            }
            if (this.layout.title.text.length) {
                this.title
                    .attr('display', null)
                    .attr('x', parseFloat(this.layout.title.x))
                    .attr('y', parseFloat(this.layout.title.y))
                    .text(this.layout.title.text)
                    .call(applyStyles, this.layout.title.style);

            } else {
                this.title.attr('display', 'none');
            }
            return this;
        }

        /**
         * Create a new data layer from a provided layout object. Should have the keys specified in `DefaultLayout`
         * Will automatically add at the top (depth/z-index) of the panel unless explicitly directed differently
         *   in the layout provided.
         *
         * **NOTE**: It is very rare that new data layers are added after a panel is rendered.
         * @public
         * @param {object} layout
         * @returns {BaseDataLayer}
         */
        addDataLayer(layout) {
            // Sanity checks
            if (typeof layout !== 'object' || typeof layout.id !== 'string' || !layout.id.length) {
                throw new Error('Invalid data layer layout');
            }
            if (typeof this.data_layers[layout.id] !== 'undefined') {
                throw new Error(`Cannot create data_layer with id '${layout.id}'; data layer with that id already exists in the panel`);
            }
            if (typeof layout.type !== 'string') {
                throw new Error('Invalid data layer type');
            }

            // If the layout defines a y axis make sure the axis number is set and is 1 or 2 (default to 1)
            if (typeof layout.y_axis == 'object' && (typeof layout.y_axis.axis == 'undefined' || ![1, 2].includes(layout.y_axis.axis))) {
                layout.y_axis.axis = 1;
            }

            // Create the Data Layer
            const data_layer = data_layers.create(layout.type, layout, this);

            // Store the Data Layer on the Panel
            this.data_layers[data_layer.id] = data_layer;

            // If a discrete z_index was set in the layout then adjust other data layer z_index values to accommodate this one
            if (data_layer.layout.z_index !== null && !isNaN(data_layer.layout.z_index)
                && this._data_layer_ids_by_z_index.length > 0) {
                // Negative z_index values should count backwards from the end, so convert negatives to appropriate values here
                if (data_layer.layout.z_index < 0) {
                    data_layer.layout.z_index = Math.max(this._data_layer_ids_by_z_index.length + data_layer.layout.z_index, 0);
                }
                this._data_layer_ids_by_z_index.splice(data_layer.layout.z_index, 0, data_layer.id);
                this._data_layer_ids_by_z_index.forEach((dlid, idx) => {
                    this.data_layers[dlid].layout.z_index = idx;
                });
            } else {
                const length = this._data_layer_ids_by_z_index.push(data_layer.id);
                this.data_layers[data_layer.id].layout.z_index = length - 1;
            }

            // Determine if this data layer was already in the layout.data_layers array.
            // If it wasn't, add it. Either way store the layout.data_layers array index on the data_layer.
            let layout_idx = null;
            this.layout.data_layers.forEach((data_layer_layout, idx) => {
                if (data_layer_layout.id === data_layer.id) {
                    layout_idx = idx;
                }
            });
            if (layout_idx === null) {
                layout_idx = this.layout.data_layers.push(this.data_layers[data_layer.id].layout) - 1;
            }
            this.data_layers[data_layer.id]._layout_idx = layout_idx;

            return this.data_layers[data_layer.id];
        }

        /**
         * Remove a data layer by id
         * @public
         * @param {string} id
         * @returns {Panel}
         */
        removeDataLayer(id) {
            const target_layer = this.data_layers[id];
            if (!target_layer) {
                throw new Error(`Unable to remove data layer, ID not found: ${id}`);
            }

            // Destroy all tooltips for the data layer
            target_layer.destroyAllTooltips();

            // Remove the svg container for the data layer if it exists
            if (target_layer.svg.container) {
                target_layer.svg.container.remove();
            }

            // Delete the data layer and its presence in the panel layout and state
            this.layout.data_layers.splice(target_layer._layout_idx, 1);
            delete this.state[target_layer._state_id];
            delete this.data_layers[id];

            // Remove the data_layer id from the z_index array
            this._data_layer_ids_by_z_index.splice(this._data_layer_ids_by_z_index.indexOf(id), 1);

            // Update layout_idx and layout.z_index values for all remaining data_layers
            this.applyDataLayerZIndexesToDataLayerLayouts();
            this.layout.data_layers.forEach((data_layer_layout, idx) => {
                this.data_layers[data_layer_layout.id]._layout_idx = idx;
            });

            return this;
        }

        /**
         * Clear all selections on all data layers
         * @public
         * @returns {Panel}
         */
        clearSelections() {
            this._data_layer_ids_by_z_index.forEach((id) => {
                this.data_layers[id].setAllElementStatus('selected', false);
            });
            return this;
        }

        /**
         * Update rendering of this panel whenever an event triggers a redraw. Assumes that the panel has already been
         *   prepared the first time via `initialize`
         * @public
         * @returns {Panel}
         */
        render() {
            // Position the panel container
            this.svg.container.attr('transform', `translate(${this.layout.origin.x}, ${this.layout.origin.y})`);

            // Set size on the clip rect
            this.svg.clipRect
                .attr('width', this.parent_plot.layout.width)
                .attr('height', this.layout.height);

            const { cliparea } = this.layout;

            // Set and position the inner border, style if necessary
            const { margin } = this.layout;
            this.inner_border
                .attr('x', margin.left)
                .attr('y', margin.top)
                .attr('width', this.parent_plot.layout.width - (margin.left + margin.right))
                .attr('height', this.layout.height - (margin.top + margin.bottom));
            if (this.layout.inner_border) {
                this.inner_border
                    .style('stroke-width', 1)
                    .style('stroke', this.layout.inner_border);
            }

            // Set/update panel title if necessary
            this.setTitle();

            // Regenerate all extents
            this.generateExtents();

            // Helper function to constrain any procedurally generated vectors (e.g. ranges, extents)
            // Constraints applied here keep vectors from going to infinity or beyond a definable power of ten
            const constrain = function (value, limit_exponent) {
                const neg_min = Math.pow(-10, limit_exponent);
                const neg_max = Math.pow(-10, -limit_exponent);
                const pos_min = Math.pow(10, -limit_exponent);
                const pos_max = Math.pow(10, limit_exponent);
                if (value === Infinity) {
                    value = pos_max;
                }
                if (value === -Infinity) {
                    value = neg_min;
                }
                if (value === 0) {
                    value = pos_min;
                }
                if (value > 0) {
                    value = Math.max(Math.min(value, pos_max), pos_min);
                }
                if (value < 0) {
                    value = Math.max(Math.min(value, neg_max), neg_min);
                }
                return value;
            };

            // Define default and shifted ranges for all axes
            const ranges = {};
            const axes_config = this.layout.axes;
            if (this.x_extent) {
                const base_x_range = { start: 0, end: this.layout.cliparea.width };
                if (axes_config.x.range) {
                    base_x_range.start = axes_config.x.range.start || base_x_range.start;
                    base_x_range.end = axes_config.x.range.end || base_x_range.end;
                }
                ranges.x = [base_x_range.start, base_x_range.end];
                ranges.x_shifted = [base_x_range.start, base_x_range.end];
            }
            if (this.y1_extent) {
                const base_y1_range = { start: cliparea.height, end: 0 };
                if (axes_config.y1.range) {
                    base_y1_range.start = axes_config.y1.range.start || base_y1_range.start;
                    base_y1_range.end = axes_config.y1.range.end || base_y1_range.end;
                }
                ranges.y1 = [base_y1_range.start, base_y1_range.end];
                ranges.y1_shifted = [base_y1_range.start, base_y1_range.end];
            }
            if (this.y2_extent) {
                const base_y2_range = { start: cliparea.height, end: 0 };
                if (axes_config.y2.range) {
                    base_y2_range.start = axes_config.y2.range.start || base_y2_range.start;
                    base_y2_range.end = axes_config.y2.range.end || base_y2_range.end;
                }
                ranges.y2 = [base_y2_range.start, base_y2_range.end];
                ranges.y2_shifted = [base_y2_range.start, base_y2_range.end];
            }

            // Shift ranges based on any drag or zoom interactions currently underway
            let { _interaction } = this.parent;
            const current_drag = _interaction.dragging;
            if (_interaction.panel_id && (_interaction.panel_id === this.id || _interaction.linked_panel_ids.includes(this.id))) {
                let anchor, scalar = null;
                if (_interaction.zooming && typeof this.x_scale == 'function') {
                    const current_extent_size = Math.abs(this.x_extent[1] - this.x_extent[0]);
                    const current_scaled_extent_size = Math.round(this.x_scale.invert(ranges.x_shifted[1])) - Math.round(this.x_scale.invert(ranges.x_shifted[0]));
                    let zoom_factor = _interaction.zooming.scale;
                    const potential_extent_size = Math.floor(current_scaled_extent_size * (1 / zoom_factor));
                    if (zoom_factor < 1 && !isNaN(this.parent.layout.max_region_scale)) {
                        zoom_factor = 1 / (Math.min(potential_extent_size, this.parent.layout.max_region_scale) / current_scaled_extent_size);
                    } else if (zoom_factor > 1 && !isNaN(this.parent.layout.min_region_scale)) {
                        zoom_factor = 1 / (Math.max(potential_extent_size, this.parent.layout.min_region_scale) / current_scaled_extent_size);
                    }
                    const new_extent_size = Math.floor(current_extent_size * zoom_factor);
                    anchor = _interaction.zooming.center - margin.left - this.layout.origin.x;
                    const offset_ratio = anchor / cliparea.width;
                    const new_x_extent_start = Math.max(Math.floor(this.x_scale.invert(ranges.x_shifted[0]) - ((new_extent_size - current_scaled_extent_size) * offset_ratio)), 1);
                    ranges.x_shifted = [ this.x_scale(new_x_extent_start), this.x_scale(new_x_extent_start + new_extent_size) ];
                } else if (current_drag) {
                    switch (current_drag.method) {
                    case 'background':
                        ranges.x_shifted[0] = +current_drag.dragged_x;
                        ranges.x_shifted[1] = cliparea.width + current_drag.dragged_x;
                        break;
                    case 'x_tick':
                        if (d3.event && d3.event.shiftKey) {
                            ranges.x_shifted[0] = +current_drag.dragged_x;
                            ranges.x_shifted[1] = cliparea.width + current_drag.dragged_x;
                        } else {
                            anchor = current_drag.start_x - margin.left - this.layout.origin.x;
                            scalar = constrain(anchor / (anchor + current_drag.dragged_x), 3);
                            ranges.x_shifted[0] = 0;
                            ranges.x_shifted[1] = Math.max(cliparea.width * (1 / scalar), 1);
                        }
                        break;
                    case 'y1_tick':
                    case 'y2_tick': {
                        const y_shifted = `y${current_drag.method[1]}_shifted`;
                        if (d3.event && d3.event.shiftKey) {
                            ranges[y_shifted][0] = cliparea.height + current_drag.dragged_y;
                            ranges[y_shifted][1] = +current_drag.dragged_y;
                        } else {
                            anchor = cliparea.height - (current_drag.start_y - margin.top - this.layout.origin.y);
                            scalar = constrain(anchor / (anchor - current_drag.dragged_y), 3);
                            ranges[y_shifted][0] = cliparea.height;
                            ranges[y_shifted][1] = cliparea.height - (cliparea.height * (1 / scalar));
                        }
                    }
                    }
                }
            }

            // Generate scales and ticks for all axes, then render them
            ['x', 'y1', 'y2'].forEach((axis) => {
                if (!this[`${axis}_extent`]) {
                    return;
                }

                // Base Scale
                this[`${axis}_scale`] = d3.scaleLinear()
                    .domain(this[`${axis}_extent`])
                    .range(ranges[`${axis}_shifted`]);

                // Shift the extent
                this[`${axis}_extent`] = [
                    this[`${axis}_scale`].invert(ranges[axis][0]),
                    this[`${axis}_scale`].invert(ranges[axis][1]),
                ];

                // Finalize Scale
                this[`${axis}_scale`] = d3.scaleLinear()
                    .domain(this[`${axis}_extent`]).range(ranges[axis]);

                // Render axis (and generate ticks as needed)
                this.renderAxis(axis);
            });

            // Establish mousewheel zoom event handers on the panel (namespacing not passed through by d3, so not used here)
            if (this.layout.interaction.scroll_to_zoom) {
                const zoom_handler = () => {
                    // Look for a shift key press while scrolling to execute.
                    // If not present, gracefully raise a notification and allow conventional scrolling
                    if (!(d3.event.shiftKey || d3.event.altKey)) {
                        if (this.parent._canInteract(this.id)) {
                            this.loader.show('Press <tt>[SHIFT]</tt> or <tt>[ALT]</tt> while scrolling to zoom').hide(1000);
                        }
                        return;
                    }
                    d3.event.preventDefault();
                    if (!this.parent._canInteract(this.id)) {
                        return;
                    }
                    const coords = d3.mouse(this.svg.container.node());
                    const delta = Math.max(-1, Math.min(1, (d3.event.wheelDelta || -d3.event.detail || -d3.event.deltaY)));
                    if (delta === 0) {
                        return;
                    }
                    this.parent._interaction = {
                        panel_id: this.id,
                        linked_panel_ids: this.getLinkedPanelIds('x'),
                        zooming: {
                            scale: (delta < 1) ? 0.9 : 1.1,
                            center: coords[0],
                        },
                    };
                    this.render();
                    // Redefine b/c might have been changed during call to parent re-render
                    _interaction = this.parent._interaction;
                    _interaction.linked_panel_ids.forEach((panel_id) => {
                        this.parent.panels[panel_id].render();
                    });
                    if (this._zoom_timeout !== null) {
                        clearTimeout(this._zoom_timeout);
                    }
                    this._zoom_timeout = setTimeout(() => {
                        this.parent._interaction = {};
                        this.parent.applyState({ start: this.x_extent[0], end: this.x_extent[1] });
                    }, 500);
                };
                // FIXME: Consider moving back to d3.zoom and rewriting drag + zoom to use behaviors.
                this.svg.container
                    .on('wheel.zoom', zoom_handler)
                    .on('mousewheel.zoom', zoom_handler)
                    .on('DOMMouseScroll.zoom', zoom_handler);
            }

            // Render data layers in order by z-index
            this._data_layer_ids_by_z_index.forEach((data_layer_id) => {
                this.data_layers[data_layer_id].draw().render();
            });

            // Rerender legend last (on top of data). A legend must have been defined at the start in order for this to work.
            if (this.legend) {
                this.legend.render();
            }
            return this;
        }

        /**
         * Add a "basic" loader to a panel. This is rarely used directly: the `show_loading_indicator` panel layout
         *   directive is the preferred way to trigger this function. The imperative form is useful if for some reason a
         *   loading indicator needs to be added only after first render.
         * This method is just a shortcut for adding the most commonly used type of loading indicator, which appears when
         *   data is requested, animates (e.g. shows an infinitely cycling progress bar as opposed to one that loads from
         *   0-100% based on actual load progress), and disappears when new data is loaded and rendered.
         *
         * @protected
         * @listens event:data_requested
         * @listens event:data_rendered
         * @param {Boolean} show_immediately
         * @returns {Panel}
         */
        addBasicLoader(show_immediately = true) {
            if (this.layout.show_loading_indicator && this._initialized) {
                // Prior to LZ 0.13, this function was called only after the plot was first rendered. Now, it is run by default.
                //   Some older pages could thus end up adding a loader twice: to avoid duplicate render events,
                //   short-circuit if a loader is already present after the first render has finished.
                return this;
            }
            if (show_immediately) {
                this.loader.show('Loading...').animate();
            }
            this.on('data_requested', () => {
                this.loader.show('Loading...').animate();
            });
            this.on('data_rendered', () => {
                this.loader.hide();
            });

            // Update layout to reflect new option
            this.layout.show_loading_indicator = true;
            return this;
        }

        /************* Private interface: only used internally */
        /** @private */
        applyDataLayerZIndexesToDataLayerLayouts () {
            this._data_layer_ids_by_z_index.forEach((dlid, idx) => {
                this.data_layers[dlid].layout.z_index = idx;
            });
        }

        /**
         * @private
         * @returns {string}
         */
        getBaseId () {
            return `${this.parent.id}.${this.id}`;
        }

        /**
         * Get an object with the x and y coordinates of the panel's origin in terms of the entire page
         * Necessary for positioning any HTML elements over the panel
         * @private
         * @returns {{x: Number, y: Number}}
         */
        _getPageOrigin() {
            const plot_origin = this.parent._getPageOrigin();
            return {
                x: plot_origin.x + this.layout.origin.x,
                y: plot_origin.y + this.layout.origin.y,
            };
        }

        /**
         * Prepare the panel for first use by performing parameter validation, creating axes, setting default dimensions,
         *   and preparing / positioning data layers as appropriate.
         * @private
         * @returns {Panel}
         */
        initializeLayout() {
            // Set panel dimensions, origin, and margin
            this.setDimensions();
            this.setOrigin();
            this.setMargin();

            // Set ranges
            // TODO: Define stub values in constructor
            this.x_range = [0, this.layout.cliparea.width];
            this.y1_range = [this.layout.cliparea.height, 0];
            this.y2_range = [this.layout.cliparea.height, 0];

            // Initialize panel axes
            ['x', 'y1', 'y2'].forEach((id) => {
                const axis = this.layout.axes[id];
                if (!Object.keys(axis).length || axis.render === false) {
                    // The default layout sets the axis to an empty object, so set its render boolean here
                    axis.render = false;
                } else {
                    axis.render = true;
                    axis.label = axis.label || null;
                }
            });

            // Add data layers (which define x and y extents)
            this.layout.data_layers.forEach((data_layer_layout) => {
                this.addDataLayer(data_layer_layout);
            });

            return this;
        }

        /**
         * Set the dimensions for the panel. If passed with no arguments will calculate optimal size based on layout
         *   directives and the available area within the plot. If passed discrete width (number) and height (number) will
         *   attempt to resize the panel to them, but may be limited by minimum dimensions defined on the plot or panel.
         *
         * @private
         * @param {number} [width]
         * @param {number} [height]
         * @returns {Panel}
         */
        setDimensions(width, height) {
            const layout = this.layout;
            if (typeof width != 'undefined' && typeof height != 'undefined') {
                if (!isNaN(width) && width >= 0 && !isNaN(height) && height >= 0) {
                    this.parent.layout.width = Math.round(+width);
                    // Ensure that the requested height satisfies all minimum values
                    layout.height = Math.max(Math.round(+height), layout.min_height);
                }
            }
            layout.cliparea.width = Math.max(this.parent_plot.layout.width - (layout.margin.left + layout.margin.right), 0);
            layout.cliparea.height = Math.max(layout.height - (layout.margin.top + layout.margin.bottom), 0);
            if (this.svg.clipRect) {
                this.svg.clipRect
                    .attr('width', this.parent.layout.width)
                    .attr('height', layout.height);
            }
            if (this._initialized) {
                this.render();
                this.curtain.update();
                this.loader.update();
                this.toolbar.update();
                if (this.legend) {
                    this.legend.position();
                }
            }
            return this;
        }

        /**
         * Set panel origin on the plot, and re-render as appropriate
         *
         * @private
         * @param {number} x
         * @param {number} y
         * @returns {Panel}
         */
        setOrigin(x, y) {
            if (!isNaN(x) && x >= 0) {
                this.layout.origin.x = Math.max(Math.round(+x), 0);
            }
            if (!isNaN(y) && y >= 0) {
                this.layout.origin.y = Math.max(Math.round(+y), 0);
            }
            if (this._initialized) {
                this.render();
            }
            return this;
        }

        /**
         * Set margins around this panel
         * @private
         * @param {number} top
         * @param {number} right
         * @param {number} bottom
         * @param {number} left
         * @returns {Panel}
         */
        setMargin(top, right, bottom, left) {
            let extra;
            const { cliparea, margin } = this.layout;
            if (!isNaN(top) && top >= 0) {
                margin.top = Math.max(Math.round(+top), 0);
            }
            if (!isNaN(right)  && right  >= 0) {
                margin.right = Math.max(Math.round(+right), 0);
            }
            if (!isNaN(bottom) && bottom >= 0) {
                margin.bottom = Math.max(Math.round(+bottom), 0);
            }
            if (!isNaN(left)   && left   >= 0) {
                margin.left = Math.max(Math.round(+left), 0);
            }
            // If the specified margins are greater than the available width, then shrink the margins.
            if (margin.top + margin.bottom > this.layout.height) {
                extra = Math.floor(((margin.top + margin.bottom) - this.layout.height) / 2);
                margin.top -= extra;
                margin.bottom -= extra;
            }
            if (margin.left + margin.right > this.parent_plot.layout.width) {
                extra = Math.floor(((margin.left + margin.right) - this.parent_plot.layout.width) / 2);
                margin.left -= extra;
                margin.right -= extra;
            }
            ['top', 'right', 'bottom', 'left'].forEach((m) => {
                margin[m] = Math.max(margin[m], 0);
            });
            cliparea.width = Math.max(this.parent_plot.layout.width - (margin.left + margin.right), 0);
            cliparea.height = Math.max(this.layout.height - (margin.top + margin.bottom), 0);
            cliparea.origin.x = margin.left;
            cliparea.origin.y = margin.top;

            if (this._initialized) {
                this.render();
            }
            return this;
        }

        /**
         * Prepare the first rendering of the panel. This includes drawing the individual data layers, but also creates shared
         *   elements such as axes,  title, and loader/curtain.
         * @private
         * @returns {Panel}
         */
        initialize() {
            // Append a container group element to house the main panel group element and the clip path
            // Position with initial layout parameters
            const base_id = this.getBaseId();
            this.svg.container = this.parent.svg.append('g')
                .attr('id', `${base_id}.panel_container`)
                .attr('transform', `translate(${this.layout.origin.x || 0}, ${this.layout.origin.y || 0})`);

            // Append clip path to the parent svg element, size with initial layout parameters
            const clipPath = this.svg.container.append('clipPath')
                .attr('id', `${base_id}.clip`);
            this.svg.clipRect = clipPath.append('rect')
                .attr('width', this.parent_plot.layout.width)
                .attr('height', this.layout.height);

            // Append svg group for rendering all panel child elements, clipped by the clip path
            this.svg.group = this.svg.container.append('g')
                .attr('id', `${base_id}.panel`)
                .attr('clip-path', `url(#${base_id}.clip)`);

            // Add curtain and loader to the panel
            /**
             * @protected
             * @member {Object}
             */
            this.curtain = generateCurtain.call(this);
            /**
             * @protected
             * @member {Object}
             */
            this.loader = generateLoader.call(this);

            if (this.layout.show_loading_indicator) {
                // Activate the loading indicator prior to first render, and only show when data is loading
                this.addBasicLoader(false);
            }

            /**
             * Create the toolbar object and hang widgets on it as defined by panel layout
             * @protected
             * @member {Toolbar}
             */
            this.toolbar = new Toolbar(this);

            // Inner border
            this.inner_border = this.svg.group.append('rect')
                .attr('class', 'lz-panel-background')
                .on('click', () => {
                    if (this.layout.background_click === 'clear_selections') {
                        this.clearSelections();
                    }
                });

            // Add the title
            /**
             * @private
             * @member {Element}
             */
            this.title = this.svg.group.append('text').attr('class', 'lz-panel-title');
            if (typeof this.layout.title != 'undefined') {
                this.setTitle();
            }

            // Initialize Axes
            this.svg.x_axis = this.svg.group.append('g')
                .attr('id', `${base_id}.x_axis`)
                .attr('class', 'lz-x lz-axis');
            if (this.layout.axes.x.render) {
                this.svg.x_axis_label = this.svg.x_axis.append('text')
                    .attr('class', 'lz-x lz-axis lz-label')
                    .attr('text-anchor', 'middle');
            }
            this.svg.y1_axis = this.svg.group.append('g')
                .attr('id', `${base_id}.y1_axis`).attr('class', 'lz-y lz-y1 lz-axis');
            if (this.layout.axes.y1.render) {
                this.svg.y1_axis_label = this.svg.y1_axis.append('text')
                    .attr('class', 'lz-y1 lz-axis lz-label')
                    .attr('text-anchor', 'middle');
            }
            this.svg.y2_axis = this.svg.group.append('g')
                .attr('id', `${base_id}.y2_axis`)
                .attr('class', 'lz-y lz-y2 lz-axis');
            if (this.layout.axes.y2.render) {
                this.svg.y2_axis_label = this.svg.y2_axis.append('text')
                    .attr('class', 'lz-y2 lz-axis lz-label')
                    .attr('text-anchor', 'middle');
            }

            // Initialize child Data Layers
            this._data_layer_ids_by_z_index.forEach((id) => {
                this.data_layers[id].initialize();
            });

            /**
             * Legend object, as defined by panel layout and child data layer layouts
             * @protected
             * @member {Legend}
             * */
            this.legend = null;
            if (this.layout.legend) {
                this.legend = new Legend(this);
            }

            // Establish panel background drag interaction mousedown event handler (on the panel background)
            if (this.layout.interaction.drag_background_to_pan) {
                const namespace = `.${this.parent.id}.${this.id}.interaction.drag`;
                const mousedown = () => this.parent.startDrag(this, 'background');
                this.svg.container.select('.lz-panel-background')
                    .on(`mousedown${namespace}.background`, mousedown)
                    .on(`touchstart${namespace}.background`, mousedown);
            }

            return this;
        }

        /**
         * Refresh the sort order of all data layers (called by data layer moveForward and moveBack methods)
         * @private
         */
        resortDataLayers() {
            const sort = [];
            this._data_layer_ids_by_z_index.forEach((id) => {
                sort.push(this.data_layers[id].layout.z_index);
            });
            this.svg.group
                .selectAll('g.lz-data_layer-container')
                .data(sort)
                .sort(d3.ascending);
            this.applyDataLayerZIndexesToDataLayerLayouts();
        }

        /**
         * Get an array of panel IDs that are axis-linked to this panel
         * @private
         * @param {('x'|'y1'|'y2')} axis
         * @returns {Array}
         */
        getLinkedPanelIds(axis) {
            axis = axis || null;
            const linked_panel_ids = [];
            if (!['x', 'y1', 'y2'].includes(axis)) {
                return linked_panel_ids;
            }
            if (!this.layout.interaction[`${axis}_linked`]) {
                return linked_panel_ids;
            }
            this.parent._panel_ids_by_y_index.forEach((panel_id) => {
                if (panel_id !== this.id && this.parent.panels[panel_id].layout.interaction[`${axis}_linked`]) {
                    linked_panel_ids.push(panel_id);
                }
            });
            return linked_panel_ids;
        }

        /**
         * Move a panel up relative to others by y-index
         * @private
         * @returns {Panel}
         */
        moveUp() {
            const { parent } = this;
            const y_index = this.layout.y_index;
            if (parent._panel_ids_by_y_index[y_index - 1]) {
                parent._panel_ids_by_y_index[y_index] = parent._panel_ids_by_y_index[y_index - 1];
                parent._panel_ids_by_y_index[y_index - 1] = this.id;
                parent.applyPanelYIndexesToPanelLayouts();
                parent.positionPanels();
            }
            return this;
        }

        /**
         * Move a panel down (y-axis) relative to others in the plot
         * @private
         * @returns {Panel}
         */
        moveDown() {
            const { _panel_ids_by_y_index } = this.parent;
            if (_panel_ids_by_y_index[this.layout.y_index + 1]) {
                _panel_ids_by_y_index[this.layout.y_index] = _panel_ids_by_y_index[this.layout.y_index + 1];
                _panel_ids_by_y_index[this.layout.y_index + 1] = this.id;
                this.parent.applyPanelYIndexesToPanelLayouts();
                this.parent.positionPanels();
            }
            return this;
        }

        /**
         * When the parent plot changes state, adjust the panel accordingly. For example, this may include fetching new data
         *   from the API as the viewing region changes
         * @private
         * @fires event:data_requested
         * @fires event:layout_changed
         * @fires event:data_rendered
         * @returns {Promise}
         */
        reMap() {
            this.emit('data_requested');
            this._data_promises = [];

            // Remove any previous error messages before attempting to load new data
            this.curtain.hide();
            // Trigger reMap on each Data Layer
            for (let id in this.data_layers) {
                try {
                    this._data_promises.push(this.data_layers[id].reMap());
                } catch (error) {
                    console.error(error);
                    this.curtain.show(error.message || error);
                }
            }
            // When all finished trigger a render
            return Promise.all(this._data_promises)
                .then(() => {
                    this._initialized = true;
                    this.render();
                    this.emit('layout_changed', true);
                    this.emit('data_rendered');
                })
                .catch((error) => {
                    console.error(error);
                    this.curtain.show(error.message || error);
                });
        }

        /**
         * Iterate over data layers to generate panel axis extents
         * @private
         * @returns {Panel}
         */
        generateExtents() {
            // Reset extents
            ['x', 'y1', 'y2'].forEach((axis) => {
                this[`${axis}_extent`] = null;
            });

            // Loop through the data layers
            for (let id in this.data_layers) {
                const data_layer = this.data_layers[id];

                // If defined and not decoupled, merge the x extent of the data layer with the panel's x extent
                if (data_layer.layout.x_axis && !data_layer.layout.x_axis.decoupled) {
                    this.x_extent = d3.extent((this.x_extent || []).concat(data_layer.getAxisExtent('x')));
                }

                // If defined and not decoupled, merge the y extent of the data layer with the panel's appropriate y extent
                if (data_layer.layout.y_axis && !data_layer.layout.y_axis.decoupled) {
                    const y_axis = `y${data_layer.layout.y_axis.axis}`;
                    this[`${y_axis}_extent`] = d3.extent((this[`${y_axis}_extent`] || []).concat(data_layer.getAxisExtent('y')));
                }

            }

            // Override x_extent from state if explicitly defined to do so
            if (this.layout.axes.x && this.layout.axes.x.extent === 'state') {
                this.x_extent = [ this.state.start, this.state.end ];
            }
            return this;
        }

        /**
         * Generate an array of ticks for an axis. These ticks are generated in one of three ways (highest wins):
         *   1. An array of specific tick marks
         *   2. Query each data layer for what ticks are appropriate, and allow a panel-level tick configuration parameter
         *     object to override the layer's default presentation settings
         *   3. Generate generic tick marks based on the extent of the data
         *
         * @private
         * @param {('x'|'y1'|'y2')} axis The string identifier of the axis
         * @returns {Number[]|Object[]}  TODO: number format?
         *   An array of numbers: interpreted as an array of axis value offsets for positioning.
         *   An array of objects: each object must have an 'x' attribute to position the tick.
         *   Other supported object keys:
         *     * text: string to render for a given tick
         *     * style: d3-compatible CSS style object
         *     * transform: SVG transform attribute string
         *     * color: string or LocusZoom scalable parameter object
         */
        generateTicks(axis) {
            // Parse an explicit 'ticks' attribute in the axis layout
            if (this.layout.axes[axis].ticks) {
                const layout = this.layout.axes[axis];

                const baseTickConfig = layout.ticks;
                if (Array.isArray(baseTickConfig)) {
                    // Array of specific ticks hard-coded into a panel will override any ticks that an individual layer might specify
                    return baseTickConfig;
                }

                if (typeof baseTickConfig === 'object') {
                    // If the layout specifies base configuration for ticks- but without specific positions- then ask each
                    //   data layer to report the tick marks that it thinks it needs
                    // TODO: Few layers currently need to specify custom ticks (which is ok!). But if it becomes common, consider adding mechanisms to deduplicate ticks across layers
                    const self = this;

                    // Pass any layer-specific customizations for how ticks are calculated. (styles are overridden separately)
                    const config = { position: baseTickConfig.position };

                    const combinedTicks = this._data_layer_ids_by_z_index.reduce((acc, data_layer_id) => {
                        const nextLayer = self.data_layers[data_layer_id];
                        return acc.concat(nextLayer.getTicks(axis, config));
                    }, []);

                    return combinedTicks.map((item) => {
                        // The layer makes suggestions, but tick configuration params specified on the panel take precedence
                        let itemConfig = {};
                        itemConfig = merge(itemConfig, baseTickConfig);
                        return merge(itemConfig, item);
                    });
                }
            }

            // If no other configuration is provided, attempt to generate ticks from the extent
            if (this[`${axis}_extent`]) {
                return prettyTicks(this[`${axis}_extent`], 'both');
            }
            return [];
        }

        /**
         * Render ticks for a particular axis
         * @private
         * @param {('x'|'y1'|'y2')} axis The identifier of the axes
         * @returns {Panel}
         */
        renderAxis(axis) {
            if (!['x', 'y1', 'y2'].includes(axis)) {
                throw new Error(`Unable to render axis; invalid axis identifier: ${axis}`);
            }

            const canRender = this.layout.axes[axis].render
                && typeof this[`${axis}_scale`] == 'function'
                && !isNaN(this[`${axis}_scale`](0));

            // If the axis has already been rendered then check if we can/can't render it
            // Make sure the axis element is shown/hidden to suit
            if (this[`${axis}_axis`]) {
                this.svg.container.select(`g.lz-axis.lz-${axis}`)
                    .style('display', canRender ? null : 'none');
            }

            if (!canRender) {
                return this;
            }

            // Axis-specific values to plug in where needed
            const axis_params = {
                x: {
                    position: `translate(${this.layout.margin.left}, ${this.layout.height - this.layout.margin.bottom})`,
                    orientation: 'bottom',
                    label_x: this.layout.cliparea.width / 2,
                    label_y: (this.layout.axes[axis].label_offset || 0),
                    label_rotate: null,
                },
                y1: {
                    position: `translate(${this.layout.margin.left}, ${this.layout.margin.top})`,
                    orientation: 'left',
                    label_x: -1 * (this.layout.axes[axis].label_offset || 0),
                    label_y: this.layout.cliparea.height / 2,
                    label_rotate: -90,
                },
                y2: {
                    position: `translate(${this.parent_plot.layout.width - this.layout.margin.right}, ${this.layout.margin.top})`,
                    orientation: 'right',
                    label_x: (this.layout.axes[axis].label_offset || 0),
                    label_y: this.layout.cliparea.height / 2,
                    label_rotate: -90,
                },
            };

            // Generate Ticks
            this[`${axis}_ticks`] = this.generateTicks(axis);

            // Determine if the ticks are all numbers (d3-automated tick rendering) or not (manual tick rendering)
            const ticksAreAllNumbers = ((ticks) => {
                for (let i = 0; i < ticks.length; i++) {
                    if (isNaN(ticks[i])) {
                        return false;
                    }
                }
                return true;
            })(this[`${axis}_ticks`]);

            // Initialize the axis; set scale and orientation
            let axis_factory;
            switch (axis_params[axis].orientation) {
            case 'right':
                axis_factory = d3.axisRight;
                break;
            case 'left':
                axis_factory = d3.axisLeft;
                break;
            case 'bottom':
                axis_factory = d3.axisBottom;
                break;
            default:
                throw new Error('Unrecognized axis orientation');
            }

            this[`${axis}_axis`] = axis_factory(this[`${axis}_scale`])
                .tickPadding(3);

            // Set tick values and format
            if (ticksAreAllNumbers) {
                this[`${axis}_axis`].tickValues(this[`${axis}_ticks`]);
                if (this.layout.axes[axis].tick_format === 'region') {
                    this[`${axis}_axis`].tickFormat((d) => positionIntToString(d, 6));
                }
            } else {
                let ticks = this[`${axis}_ticks`].map((t) => {
                    return (t[axis.substr(0, 1)]);
                });
                this[`${axis}_axis`].tickValues(ticks)
                    .tickFormat((t, i) => {
                        return this[`${axis}_ticks`][i].text;
                    });
            }

            // Position the axis in the SVG and apply the axis construct
            this.svg[`${axis}_axis`]
                .attr('transform', axis_params[axis].position)
                .call(this[`${axis}_axis`]);

            // If necessary manually apply styles and transforms to ticks as specified by the layout
            if (!ticksAreAllNumbers) {
                const tick_selector = d3.selectAll(`g#${this.getBaseId().replace('.', '\\.')}\\.${axis}_axis g.tick`);
                const panel = this;
                tick_selector.each(function (d, i) {
                    const selector = d3.select(this).select('text');
                    if (panel[`${axis}_ticks`][i].style) {
                        applyStyles(selector, panel[`${axis}_ticks`][i].style);
                    }
                    if (panel[`${axis}_ticks`][i].transform) {
                        selector.attr('transform', panel[`${axis}_ticks`][i].transform);
                    }
                });
            }

            // Render the axis label if necessary
            const label = this.layout.axes[axis].label || null;
            if (label !== null) {
                this.svg[`${axis}_axis_label`]
                    .attr('x', axis_params[axis].label_x)
                    .attr('y', axis_params[axis].label_y)
                    .text(parseFields(label, this.state))
                    .attr('fill', 'currentColor');
                if (axis_params[axis].label_rotate !== null) {
                    this.svg[`${axis}_axis_label`]
                        .attr('transform', `rotate(${axis_params[axis].label_rotate} ${axis_params[axis].label_x}, ${axis_params[axis].label_y})`);
                }
            }

            // Attach interactive handlers to ticks as needed
            ['x', 'y1', 'y2'].forEach((axis) => {
                if (this.layout.interaction[`drag_${axis}_ticks_to_scale`]) {
                    const namespace = `.${this.parent.id}.${this.id}.interaction.drag`;
                    const tick_mouseover = function() {
                        if (typeof d3.select(this).node().focus == 'function') {
                            d3.select(this).node().focus();
                        }
                        let cursor = (axis === 'x') ? 'ew-resize' : 'ns-resize';
                        if (d3.event && d3.event.shiftKey) {
                            cursor = 'move';
                        }
                        d3.select(this)
                            .style('font-weight', 'bold')
                            .style('cursor', cursor )
                            .on(`keydown${namespace}`, tick_mouseover)
                            .on(`keyup${namespace}`, tick_mouseover);
                    };
                    this.svg.container.selectAll(`.lz-axis.lz-${axis} .tick text`)
                        .attr('tabindex', 0) // necessary to make the tick focusable so keypress events can be captured
                        .on(`mouseover${namespace}`, tick_mouseover)
                        .on(`mouseout${namespace}`, function() {
                            d3.select(this)
                                .style('font-weight', 'normal')
                                .on(`keydown${namespace}`, null)
                                .on(`keyup${namespace}`, null);
                        })
                        .on(`mousedown${namespace}`, () => {
                            this.parent.startDrag(this, `${axis}_tick`);
                        });
                }
            });

            return this;
        }

        /**
         * Force the height of this panel to the largest absolute height of the data in
         *   all child data layers (if not null for any child data layers)
         * @private
         * @param {number|null} [target_height] A target height, which will be used in situations when the expected height can be
         *   pre-calculated (eg when the layers are transitioning)
         */
        scaleHeightToData(target_height) {
            target_height = +target_height || null;
            if (target_height === null) {
                this._data_layer_ids_by_z_index.forEach((id) => {
                    const dh = this.data_layers[id].getAbsoluteDataHeight();
                    if (+dh) {
                        if (target_height === null) {
                            target_height = +dh;
                        } else {
                            target_height = Math.max(target_height, +dh);
                        }
                    }
                });
            }
            if (+target_height) {
                target_height += +this.layout.margin.top + +this.layout.margin.bottom;
                // FIXME: plot.setDimensions calls panel.setDimensions (though without arguments)
                this.setDimensions(this.parent_plot.layout.width, target_height);
                this.parent.setDimensions();
                this.parent.positionPanels();
            }
        }

        /**
         * Set/unset element statuses across all data layers
         * @private
         * @param {String} status
         * @param {Boolean} toggle
         */
        setAllElementStatus(status, toggle) {
            this._data_layer_ids_by_z_index.forEach((id) => {
                this.data_layers[id].setAllElementStatus(status, toggle);
            });
        }
    }

    STATUSES.verbs.forEach((verb, idx) => {
        const adjective = STATUSES.adjectives[idx];
        const antiverb = `un${verb}`;

        // Set/unset status for all elements
        /**
         * @private
         * @function highlightAllElements
         */
        /**
         *  @private
         *  @function selectAllElements
         */
        /**
         *  @private
         *  @function fadeAllElements
         */
        /**
         *  @private
         *  @function hideAllElements
         */
        Panel.prototype[`${verb}AllElements`] = function() {
            this.setAllElementStatus(adjective, true);
            return this;
        };

        /**
         * @private
         * @function unhighlightAllElements
         */
        /**
         *  @private
         *  @function unselectAllElements
         */
        /**
         * @private
         * @function unfadeAllElements
         */
        /**
         *  @private
         *  @function unhideAllElements
         */
        Panel.prototype[`${antiverb}AllElements`] = function() {
            this.setAllElementStatus(adjective, false);
            return this;
        };
    });

    export {Panel as default};