Source: components/plot.js

import * as d3 from 'd3';

import {deepCopy, merge} from '../helpers/layouts';
import Requester from '../data/requester';
import Toolbar from './toolbar';
import Panel from './panel';
import {generateCurtain, generateLoader} from '../helpers/common';

/**
 * Default/ expected configuration parameters for basic plotting; most plots will override
 *
 * @memberof Plot
 * @protected
 * @static
 * @type {Object}
 */
const default_layout = {
    state: {},
    width: 800,
    min_width: 400,
    min_region_scale: null,
    max_region_scale: null,
    responsive_resize: false,
    panels: [],
    toolbar: {
        widgets: [],
    },
    panel_boundaries: true,
    mouse_guide: true,
};


/**
 * Fields common to every event emitted by LocusZoom. This is not an actual event that should ever be used directly;
 *  see list below.
 *
 * Note: plot-level listeners *can* be defined for this event, but you should almost never do this.
 *  Use the most specific event name to describe the thing you are interested in.
 *
 * Listening to 'any_lz_event' is only for advanced usages, such as proxying (repeating) LZ behavior to a piece of
 *  wrapper code. One example is converting all LocusZoom events to vue.js events.
 *
 * @event any_lz_event
 * @type {object}
 * @property {string} sourceID The fully qualified ID of the entity that originated the event, eg `lz-plot.association`
 * @property {Plot|Panel} target A reference to the plot or panel instance that originated the event.
 * @property {object|null} data Additional data provided. (see event-specific documentation)
 */

/**
 * A panel was removed from the plot. Commonly initiated by the "remove panel" toolbar widget.
 * @event panel_removed
 * @property {string} data The id of the panel that was removed (eg 'genes')
 * @see event:any_lz_event
 */

/**
 * A request for new or cached data was initiated. This can be used for, eg, showing data loading indicators.
 * @event data_requested
 * @see event:any_lz_event
 */

/**
 * A request for new data has completed, and all data has been rendered in the plot.
 * @event data_rendered
 * @see event:any_lz_event
 */

/**
 * One particular data layer has completed a request for data. This event is primarily used internally by the `subscribeToData` function, and the syntax may change in the future.
 * @event data_from_layer
 * @property {object} data
 * @property {String} data.layer The fully qualified ID of the layer emitting this event
 * @property {Object[]} data.content The data used to draw this layer: an array where each element represents one row/ datum
 *  element. It reflects all namespaces and data operations used by that layer.
 * @see event:any_lz_event
 */


/**
 * An action occurred that changed, or could change, the layout.
 *   Many rerendering operations can fire this event and it is somewhat generic: it includes resize, highlight,
 *   and rerender on new data.
 * Caution: Direct layout mutations might not be captured by this event. It is deprecated due to its limited utility.
 * @event layout_changed
 * @deprecated
 * @see event:any_lz_event
 */

/**
 * The user has requested any state changes, eg via `plot.applyState`. This reports the original requested values even
 *  if they are overridden by plot logic. Only triggered when a state change causes a re-render.
 * @event state_changed
 * @property {object} data The set of all state changes requested
 * @see event:any_lz_event
 * @see {@link event:region_changed} for a related event that provides more accurate information in some cases
 */

/**
 * The plot region has changed. Reports the actual coordinates of the plot after the zoom event. If plot.applyState is
 *  called with an invalid region (eg zooming in or out too far), this reports the actual final coordinates, not what was requested.
 *  The actual coordinates are subject to region min/max, etc.
 * @event region_changed
 * @property {object} data The {chr, start, end} coordinates of the requested region.
 * @see event:any_lz_event
 */

/**
 * Indicate whether the element was selected (or unselected)
 * @event element_selection
 * @property {object} data An object with keys { element, active }, representing the datum bound to the element and the
 *   selection status (boolean)
 * @see {@link event:element_clicked} if you are interested in tracking clicks that result in other behaviors, like links
 * @see event:any_lz_event
 */

/**
 * Indicates whether an element was clicked. (regardless of the behavior associated with clicking)
 * @event element_clicked
 * @see {@link event:element_selection} for a more specific and more frequently useful event
 * @see event:any_lz_event
 */

/**
 * Indicate whether a match was requested from within a data layer.
 * @event match_requested
 * @property {object} data An object of `{value, active}` representing the scalar value to be matched and whether a match is
 *   being initiated or canceled
 * @see event:any_lz_event
 */

/**
 * Check that position fields (chr, start, end) are provided where appropriate, and ensure that the plot fits within
 *  any constraints specified by the layout
 *
 * This function has side effects; it mutates the proposed state in order to meet certain bounds checks etc.
 * @private
 * @param {Object} new_state
 * @param {Number} new_state.chr
 * @param {Number} new_state.start
 * @param {Number} new_state.end
 * @param {Object} layout
 * @returns {*|{}}
 */
function _updateStatePosition(new_state, layout) {

    new_state = new_state || {};
    layout = layout || {};

    // If a "chr", "start", and "end" are present then resolve start and end
    // to numeric values that are not decimal, negative, or flipped
    let validated_region = false;
    let attempted_midpoint = null;
    let attempted_scale;
    if (typeof new_state.chr != 'undefined' && typeof new_state.start != 'undefined' && typeof new_state.end != 'undefined') {
        // Determine a numeric scale and midpoint for the attempted region,
        new_state.start = Math.max(parseInt(new_state.start), 1);
        new_state.end = Math.max(parseInt(new_state.end), 1);
        if (isNaN(new_state.start) && isNaN(new_state.end)) {
            new_state.start = 1;
            new_state.end = 1;
            attempted_midpoint = 0.5;
            attempted_scale = 0;
        } else if (isNaN(new_state.start) || isNaN(new_state.end)) {
            attempted_midpoint = new_state.start || new_state.end;
            attempted_scale = 0;
            new_state.start = (isNaN(new_state.start) ? new_state.end : new_state.start);
            new_state.end = (isNaN(new_state.end) ? new_state.start : new_state.end);
        } else {
            attempted_midpoint = Math.round((new_state.start + new_state.end) / 2);
            attempted_scale = new_state.end - new_state.start;
            if (attempted_scale < 0) {
                const temp = new_state.start;
                new_state.end = new_state.start;
                new_state.start = temp;
                attempted_scale = new_state.end - new_state.start;
            }
            if (attempted_midpoint < 0) {
                new_state.start = 1;
                new_state.end = 1;
                attempted_scale = 0;
            }
        }
        validated_region = true;
    }

    // Constrain w/r/t layout-defined minimum region scale
    if (layout.min_region_scale && validated_region && attempted_scale < layout.min_region_scale) {
        new_state.start = Math.max(attempted_midpoint - Math.floor(layout.min_region_scale / 2), 1);
        new_state.end = new_state.start + layout.min_region_scale;
    }

    // Constrain w/r/t layout-defined maximum region scale
    if (layout.max_region_scale && validated_region && attempted_scale > layout.max_region_scale) {
        new_state.start = Math.max(attempted_midpoint - Math.floor(layout.max_region_scale / 2), 1);
        new_state.end = new_state.start + layout.max_region_scale;
    }

    return new_state;
}


class Plot {
    /**
     * An independent LocusZoom object that renders a unique set of data and subpanels.
     * Many such LocusZoom objects can exist simultaneously on a single page, each having its own layout.
     *
     * This creates a new plot instance, but does not immediately render it. For practical use, it may be more convenient
     * to use the `LocusZoom.populate` helper method.
     *
     * @param {String} id The ID of the plot. Often corresponds to the ID of the container element on the page
     *   where the plot is rendered..
     * @param {DataSources} datasource Ensemble of data providers used by the plot
     * @param {object} [layout.state] Initial state parameters; the most common options are 'chr', 'start', and 'end'
     *   to specify initial region view
     * @param {number} [layout.width=800] The width of the plot and all child panels
     * @param {number} [layout.min_width=400] Do not allow the panel to be resized below this width
     * @param {number} [layout.min_region_scale] The minimum region width (do not allow the user to zoom smaller than this region size)
     * @param {number} [layout.max_region_scale] The maximum region width (do not allow the user to zoom wider than this region size)
     * @param {boolean} [layout.responsive_resize=false] Whether to resize plot width as the screen is resized
     * @param {Object[]} [layout.panels] Configuration options for each panel to be added
     * @param {module:LocusZoom_Widgets[]} [layout.toolbar.widgets] Configuration options for each widget to place on the
     *   plot-level toolbar
     * @param {boolean} [layout.panel_boundaries=true] Whether to show interactive resize handles to change panel dimensions
     * @param {boolean} [layout.mouse_guide=true] Whether to always show horizontal and vertical dotted lines that intersect at the current location of the mouse pointer.
     *   This line spans the entire plot area and is especially useful for plots with multiple panels.
     */
    constructor(id, datasource, layout) {
        /**
         * @private
         * @member Boolean}
         */
        this._initialized = false;

        /**
         *  @private
         *  @member {Plot}
         */
        this.parent_plot = this;

        /**
         *  @public
         *  @member {String}
         */
        this.id = id;

        /**
         * @private
         * @member {Element}
         */
        this.container = null;
        /**
         * Selector for a node that will contain the plot. (set externally by populate methods)
         * @private
         * @member {d3.selection}
         */
        this.svg = null;

        /**
         * Direct access to panel instances, keyed by panel ID. Used primarily for introspection/ development.
         *  @public
         *  @member {Object.<String, Panel>}
         */
        this.panels = {};
        /**
         * TODO: This is currently used by external classes that manipulate the parent and may indicate room for a helper method in the api to coordinate boilerplate
         * @private
         * @member {String[]}
         */
        this._panel_ids_by_y_index = [];

        /**
         * Track update operations (reMap) performed on all child panels, and notify the parent plot when complete
         * TODO: Reconsider whether we need to be tracking this as global state outside of context of specific operations
         * @ignore
         * @protected
         * @member {Promise[]}
         */
        this._remap_promises = [];


        /**
         * The current layout options for the plot, including the effect of any resizing events or dynamically
         *  generated config produced during rendering options.
         * @public
         * @type {Object}
         */
        this.layout = layout;
        merge(this.layout, default_layout); // TODO: evaluate how the default layout is applied

        /**
         * Values in the layout object may change during rendering etc. Retain a copy of the original plot options.
         * This is useful for, eg, dynamically generated color schemes that need to start from scratch when new data is
         * loaded: it contains the "defaults", not just the result of a calculated value.
         * @ignore
         * @protected
         * @member {Object}
         */
        this._base_layout = deepCopy(this.layout);

        /**
         * Create a shortcut to the state in the layout on the Plot. Tracking in the layout allows the plot to be created
         *   with initial state/setup.
         *
         * Tracks state of the plot, eg start and end position
         * @public
         * @member {Object}
         */
        this.state = this.layout.state;

        /**
         * @private
         * @member {Requester}
         */
        this.lzd = new Requester(datasource);

        /**
         * Track global event listeners that are used by LZ. This allows cleanup of listeners when plot is destroyed.
         * @private
         * @member {Map} A nested hash of entries: { parent: {event_name: [listeners] } }
         */
        this._external_listeners = new Map();

        /**
         * 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 = {};

        /**
         * @callback eventCallback
         * @param {object} eventData A description of the event
         * @param {String|null} eventData.sourceID The unique identifier (eg plot or parent name) of the element that
         *  triggered the event. Will be automatically filled in if not explicitly provided.
         * @param {Object|null} eventData.context Any additional information to be passed to the callback, eg the data
         *   associated with a clicked plot element
         */

        /**
         * Event information describing interaction (e.g. panning and zooming) is stored on the plot
         * TODO: Add/ document details of interaction structure as we expand
         * @private
         * @member {{panel_id: String, linked_panel_ids: Array, x_linked: *, dragging: *, zooming: *}}
         * @returns {Plot}
         */
        this._interaction = {};

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

    /******* User-facing methods that allow manipulation of the plot instance: the public interface */

    /**
     * There are several events that a LocusZoom plot 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 `plot.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 an event. Consult documentation for the names of built-in events.
     * @param {eventCallback} hook
     * @returns {function} The registered event listener
     */
    on(event, hook) {
        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
     * @see {@link event:any_lz_event} for a list of pre-defined events commonly used by LocusZoom
     * @param {String} event The name of an event (as defined in `event_hooks`)
     * @param {eventCallback} [hook] The callback to deregister
     * @returns {Plot}
     */
    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
     * @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
     * @returns {Plot}
     */
    emit(event, eventData) {
        // TODO: there are small differences between the emit implementation between plots and panels. In the future,
        //  DRY this code via mixins, and make sure to keep the interfaces compatible when refactoring.
        const these_hooks = this._event_hooks[event];
        if (typeof event != 'string') {
            throw new Error(`LocusZoom attempted to throw an invalid event: ${event.toString()}`);
        } else if (!these_hooks && !this._event_hooks['any_lz_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?
            return this;
        }
        const sourceID = this.getBaseId();
        let eventContext;
        if (eventData && eventData.sourceID) {
            // If we detect that an event originated elsewhere (via bubbling or externally), preserve the context
            //  when re-emitting the event to plot-level listeners
            eventContext = eventData;
        } else {
            eventContext = {sourceID: sourceID, target: this, data: eventData || null};
        }
        if (these_hooks) {
            // This event may have no hooks, but we could be passing by on our way to any_lz_event (below)
            these_hooks.forEach((hookToRun) => {
                // By default, any handlers fired here (either directly, or bubbled) will see the plot 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);
            });
        }

        // At the plot level (only), all events will be re-emitted under the special name "any_lz_event"- a single place to
        //  globally listen to every possible event.
        // This is not intended for direct use. It is for UI frameworks like Vue.js, which may need to wrap LZ
        //   instances and proxy all events to their own declarative event system
        if (event !== 'any_lz_event') {
            const anyEventData = Object.assign({ event_name: event }, eventContext);
            this.emit('any_lz_event', anyEventData);
        }
        return this;
    }

    /**
     * Create a new panel from a layout, and handle the work of initializing and placing the panel on the plot
     * @public
     * @param {Object} layout
     * @returns {Panel}
     */
    addPanel(layout) {
        // Sanity checks
        if (typeof layout !== 'object') {
            throw new Error('Invalid panel layout');
        }

        // Create the Panel and set its parent
        const panel = new Panel(layout, this);

        // Store the Panel on the Plot
        this.panels[panel.id] = panel;

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

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

        // Call positionPanels() to keep panels from overlapping and ensure filling all available vertical space
        if (this._initialized) {
            this.positionPanels();
            // Initialize and load data into the new panel
            this.panels[panel.id].initialize();
            this.panels[panel.id].reMap();
            // An extra call to setDimensions with existing discrete dimensions fixes some rounding errors with tooltip
            // positioning. TODO: make this additional call unnecessary.
            this.setDimensions(this.layout.width, this._total_height);
        }
        return this.panels[panel.id];
    }

    /**
     * Clear all state, tooltips, and other persisted data associated with one (or all) panel(s) in the plot
     *
     * This is useful when reloading an existing plot with new data, eg "click for genome region" links.
     *   This is a utility method for custom usage. It is not fired automatically during normal rerender of existing panels
     * TODO: Is this method still necessary in modern usage? Hide from docs for now.
     * @public
     * @ignore
     * @param {String} [panelId] If provided, clear state for only this panel. Otherwise, clear state for all panels.
     * @param {('wipe'|'reset')} [mode='wipe'] Optionally specify how state should be cleared. `wipe` deletes all data
     *   and is useful for when the panel is being removed; `reset` is best when the panel will be reused in place.
     * @returns {Plot}
     */
    clearPanelData(panelId, mode) {
        mode = mode || 'wipe';

        // TODO: Add unit tests for this method
        let panelsList;
        if (panelId) {
            panelsList = [panelId];
        } else {
            panelsList = Object.keys(this.panels);
        }

        panelsList.forEach((pid) => {
            this.panels[pid]._data_layer_ids_by_z_index.forEach((dlid) => {
                const layer = this.panels[pid].data_layers[dlid];
                layer.destroyAllTooltips();

                delete layer._layer_state;
                delete this.layout.state[layer._state_id];
                if (mode === 'reset') {
                    layer._setDefaultState();
                }
            });
        });
        return this;
    }

    /**
     * Remove the panel from the plot, and clear any state, tooltips, or other visual elements belonging to nested content
     * @public
     * @fires event:panel_removed
     * @param {String} id
     * @returns {Plot}
     */
    removePanel(id) {
        const target_panel = this.panels[id];
        if (!target_panel) {
            throw new Error(`Unable to remove panel, ID not found: ${id}`);
        }

        // Hide all panel boundaries
        this._panel_boundaries.hide();

        // Destroy all tooltips and state vars for all data layers on the panel
        this.clearPanelData(id);

        // Remove all panel-level HTML overlay elements
        target_panel.loader.hide();
        target_panel.toolbar.destroy(true);
        target_panel.curtain.hide();

        // Remove the svg container for the panel if it exists
        if (target_panel.svg.container) {
            target_panel.svg.container.remove();
        }

        // Delete the panel and its presence in the plot layout and state
        this.layout.panels.splice(target_panel._layout_idx, 1);
        delete this.panels[id];
        delete this.layout.state[id];

        // Update layout_idx values for all remaining panels
        this.layout.panels.forEach((panel_layout, idx) => {
            this.panels[panel_layout.id]._layout_idx = idx;
        });

        // Remove the panel id from the y_index array
        this._panel_ids_by_y_index.splice(this._panel_ids_by_y_index.indexOf(id), 1);
        this.applyPanelYIndexesToPanelLayouts();

        // Call positionPanels() to keep panels from overlapping and ensure filling all available vertical space
        if (this._initialized) {
            this.positionPanels();
            // An extra call to setDimensions with existing discrete dimensions fixes some rounding errors with tooltip
            // positioning. TODO: make this additional call unnecessary.
            this.setDimensions(this.layout.width, this._total_height);
        }

        this.emit('panel_removed', id);

        return this;
    }

    /**
     * Refresh (or fetch) a plot's data from sources, regardless of whether position or state has changed
     * @public
     * @returns {Promise}
     */
    refresh() {
        return this.applyState();
    }

    /**
     * A user-defined callback function that can receive (and potentially act on) new plot data.
     * @callback externalDataCallback
     * @param {Object} new_data The body resulting from a data request. This represents the same information that would be passed to
     *  a data layer making an equivalent request.
     * @param {Object} plot A reference to the plot object. This can be useful for listeners that react to the
     *   structure of the data, instead of just displaying something.
     */

    /**
     * A user-defined callback function that can respond to errors received during a previous operation
     * @callback externalErrorCallback
     * @param err A representation of the error that occurred
     */

    /**
     * Allow newly fetched data to be made available outside the LocusZoom plot. For example, a callback could be
     *  registered to draw an HTML table of top GWAS hits, and update that table whenever the plot region changes.
     *
     * This is a convenience method for external hooks. It registers an event listener and returns parsed data,
     *  using the same fields syntax and underlying methods as data layers.
     *
     * @public
     * @listens event:data_rendered
     * @listens event:data_from_layer
     * @param {Object} [opts] Options
     * @param {String} [opts.from_layer=null] The ID string (`panel_id.layer_id`) of a specific data layer to be watched.
     * @param {Object} [opts.namespace] An object specifying where to find external data. See data layer documentation for details.
     * @param {Object} [opts.data_operations] An array of data operations. If more than one source of data is requested,
     *  this is usually required in order to specify dependency order and join operations. See data layer documentation for details.
     * @param {externalErrorCallback} [opts.onerror] User defined function that is automatically called if a problem
     *  occurs during the data request or subsequent callback operations
     * @param {externalDataCallback} success_callback Used defined function that is automatically called any time that
     *  new data is received by the plot. Receives two arguments: (data, plot).
     *  @return {function} The newly created event listener, to allow for later cleanup/removal
     */
    subscribeToData(opts, success_callback) {
        const { from_layer, namespace, data_operations, onerror } = opts;

        // Register an event listener that is notified whenever new data has been rendered
        const error_callback = onerror || function (err) {
            console.error('An error occurred while acting on an external callback', err);
        };

        if (from_layer) {
            // Option 1: Subscribe to a data layer. Receive a copy of the exact data it receives; no need to duplicate NS or data operations code in two places.
            const base_prefix = `${this.getBaseId()}.`;
            // Allow users to provide either `plot.panel.layer`, or `panel.layer`. The latter usually leads to more reusable code.
            const layer_target = from_layer.startsWith(base_prefix) ? from_layer : `${base_prefix}${from_layer}`;

            // Ensure that a valid layer exists to watch
            let is_valid_layer = false;
            for (let p of Object.values(this.panels)) {
                is_valid_layer = Object.values(p.data_layers).some((d) => d.getBaseId() === layer_target);
                if (is_valid_layer) {
                    break;
                }
            }
            if (!is_valid_layer) {
                throw new Error(`Could not subscribe to unknown data layer ${layer_target}`);
            }

            const listener = (eventData) => {
                if (eventData.data.layer !== layer_target) {
                    // Same event name fires for many layers; only fire success cb for the one layer we want
                    return;
                }
                try {
                    success_callback(eventData.data.content, this);
                } catch (error) {
                    error_callback(error);
                }
            };

            this.on('data_from_layer', listener);
            return listener;
        }

        // Second option: subscribe to an explicit list of fields and namespaces. This is useful if the same piece of
        //   data has to be displayed in multiple ways, eg if we just want an annotation (which is normally visualized
        //   in connection to some other visualization)
        if (!namespace) {
            throw new Error("subscribeToData must specify the desired data: either 'from_layer' or 'namespace' option");
        }

        const [entities, dependencies] = this.lzd.config_to_sources(namespace, data_operations); // Does not pass reference to initiator- we don't want external subscribers with the power to mutate the whole plot.
        const listener = () => {
            try {
                // NOTE TO FUTURE SELF: since this event does something async and not tied to a returned promise, unit tests will behave strangely,
                //  even though this method totally works. Don't spend another hour scratching your head; this is the line to blame.
                this.lzd.getData(this.state, entities, dependencies)
                    .then((new_data) => success_callback(new_data, this))
                    .catch(error_callback);
            } catch (error) {
                // In certain cases, errors are thrown before a promise can be generated, and LZ error display seems to rely on these errors bubbling up
                error_callback(error);
            }
        };
        this.on('data_rendered', listener);
        return listener;
    }

    /**
     * Update state values and trigger a pull for fresh data on all data sources for all data layers
     * @public
     * @param {Object} state_changes
     * @returns {Promise} A promise that resolves when all data fetch and update operations are complete
     * @listens event:match_requested
     * @fires event:data_requested
     * @fires event:layout_changed
     * @fires event:data_rendered
     * @fires event:state_changed
     * @fires event:region_changed
     */
    applyState(state_changes) {
        state_changes = state_changes || {};
        if (typeof state_changes != 'object') {
            throw new Error(`applyState only accepts an object; ${typeof state_changes} given`);
        }

        // Track what parameters will be modified. For bounds checking, we must take some preset values into account.
        let mods = { chr: this.state.chr, start: this.state.start, end: this.state.end };
        for (let property in state_changes) {
            mods[property] = state_changes[property];
        }
        mods = _updateStatePosition(mods, this.layout);

        // Apply new state to the actual state
        for (let property in mods) {
            this.state[property] = mods[property];
        }

        // Generate requests for all panels given new state
        this.emit('data_requested');
        this._remap_promises = [];
        this.loading_data = true;
        for (let id in this.panels) {
            this._remap_promises.push(this.panels[id].reMap());
        }

        return Promise.all(this._remap_promises)
            .catch((error) => {
                console.error(error);
                this.curtain.show(error.message || error);
                this.loading_data = false;
            })
            .then(() => {
                // Update toolbar / widgets
                this.toolbar.update();

                // Apply panel-level state values
                this._panel_ids_by_y_index.forEach((panel_id) => {
                    const panel = this.panels[panel_id];
                    panel.toolbar.update();
                    // Apply data-layer-level state values
                    panel._data_layer_ids_by_z_index.forEach((data_layer_id) => {
                        panel.data_layers[data_layer_id].applyAllElementStatus();
                    });
                });

                // Emit events
                this.emit('layout_changed');
                this.emit('data_rendered');
                this.emit('state_changed', state_changes);

                // An interesting quirk of region changing in LZ: the final region is not always the same as the requested region
                //   (example: zoom out beyond max, or request non-integer position)
                // Echo the actual plot region as the final source of truth
                const { chr, start, end } = this.state;
                const position_changed = Object.keys(state_changes)
                    .some((key) => ['chr', 'start', 'end'].includes(key));

                if (position_changed) {
                    this.emit('region_changed', { chr, start, end });
                }

                this.loading_data = false;
            });
    }

    /**
     * Keep a record of event listeners that are defined outside of the LocusZoom boundary (and therefore would not
     *  get cleaned up when the plot was removed from the DOM). For example, window resize or mouse events.
     * This allows safe cleanup of the plot on removal from the page. This method is useful for authors of LocusZoom plugins.
     * @param {Node} target The node on which the listener has been defined
     * @param {String} event_name
     * @param {function} listener The handle for the event listener to be cleaned up
     */
    trackExternalListener(target, event_name, listener) {
        if (!this._external_listeners.has(target)) {
            this._external_listeners.set(target, new Map());
        }
        const container = this._external_listeners.get(target);

        const tracker = container.get(event_name) || [];
        if (!tracker.includes(listener)) {
            tracker.push(listener);
        }
        container.set(event_name, tracker);
    }

    /**
     * Remove the plot from the page, and clean up any globally registered event listeners
     *
     * Internally, the plot retains references to some nodes via selectors; it may be useful to delete the plot
     *  instance after calling this method
     */
    destroy() {
        for (let [target, registered_events] of this._external_listeners.entries()) {
            for (let [event_name, listeners] of registered_events) {
                for (let listener of listeners) {
                    target.removeEventListener(event_name, listener);
                }
            }
        }

        // Clear the SVG, plus other HTML nodes (like toolbar) that live under the same parent
        const parent = this.svg.node().parentNode;
        if (!parent) {
            throw new Error('Plot has already been removed');
        }
        while (parent.lastElementChild) {
            parent.removeChild(parent.lastElementChild);
        }
        // Clear toolbar event listeners defined on the parent lz-container. As of 2020 this appears to be the
        //  state of the art cross-browser DOM API for this task.
        // eslint-disable-next-line no-self-assign
        parent.outerHTML = parent.outerHTML;

        this._initialized = false;

        this.svg = null;
        this.panels = null;
    }

    /**
     * Plots can change how data is displayed by layout mutations. In rare cases, such as swapping from one source of LD to another,
     *   these layout mutations won't be picked up instantly. This method notifies the plot to recalculate any cached properties,
     *   like data fetching logic, that might depend on initial layout. It does not trigger a re-render by itself.
     * @public
     */
    mutateLayout() {
        Object.values(this.panels).forEach((panel) => {
            Object.values(panel.data_layers).forEach((layer) => layer.mutateLayout());
        });
    }

    /******* The private interface: methods only used by LocusZoom internals */
    /**
     * Track whether the target panel can respond to mouse interaction events
     * @private
     * @param {String} panel_id
     * @returns {boolean}
     */
    _canInteract(panel_id) {
        panel_id = panel_id || null;
        const { _interaction } = this;
        if (panel_id) {
            return ((typeof _interaction.panel_id == 'undefined' || _interaction.panel_id === panel_id) && !this.loading_data);
        } else {
            return !(_interaction.dragging || _interaction.zooming || this.loading_data);
        }
    }

    /**
     * Get an object with the x and y coordinates of the plot's origin in terms of the entire page
     *  This returns a result with absolute position relative to the page, regardless of current scrolling
     * Necessary for positioning any HTML elements over the plot
     * @private
     * @returns {{x: Number, y: Number, width: Number, height: Number}}
     */
    _getPageOrigin() {
        const bounding_client_rect = this.svg.node().getBoundingClientRect();
        let x_offset = document.documentElement.scrollLeft || document.body.scrollLeft;
        let y_offset = document.documentElement.scrollTop || document.body.scrollTop;
        let container = this.svg.node();
        while (container.parentNode !== null) {
            // TODO: Recursively seeks offsets for highest non-static parent node. This can lead to incorrect
            //   calculations of, for example, x coordinate relative to the page. Revisit this logic.
            container = container.parentNode;
            if (container !== document && d3.select(container).style('position') !== 'static') {
                x_offset = -1 * container.getBoundingClientRect().left;
                y_offset = -1 * container.getBoundingClientRect().top;
                break;
            }
        }
        return {
            x: x_offset + bounding_client_rect.left,
            y: y_offset + bounding_client_rect.top,
            width: bounding_client_rect.width,
            height: bounding_client_rect.height,
        };
    }

    /**
     * Get the top and left offset values for the plot's container element (the div that was populated)
     * @private
     * @returns {{top: number, left: number}}
     */
    getContainerOffset() {
        const offset = { top: 0, left: 0 };
        let container = this.container.offsetParent || null;
        while (container !== null) {
            offset.top += container.offsetTop;
            offset.left += container.offsetLeft;
            container = container.offsetParent || null;
        }
        return offset;
    }

    /**
     * Notify each child panel of the plot of changes in panel ordering/ arrangement
     * @private
     */
    applyPanelYIndexesToPanelLayouts () {
        this._panel_ids_by_y_index.forEach((pid, idx) => {
            this.panels[pid].layout.y_index = idx;
        });
    }

    /**
     * Get the qualified ID pathname for the plot
     * @private
     * @returns {String}
     */
    getBaseId () {
        return this.id;
    }

    /**
     * Resize the plot to fit the bounding container
     * @private
     * @returns {Plot}
     */
    rescaleSVG() {
        const clientRect = this.svg.node().getBoundingClientRect();
        this.setDimensions(clientRect.width, clientRect.height);
        return this;
    }

    /**
     * Prepare the plot for first use by performing parameter validation, setting up panels, and calculating dimensions
     * @private
     * @returns {Plot}
     */
    initializeLayout() {
        // Sanity check layout values
        if (isNaN(this.layout.width) || this.layout.width <= 0) {
            throw new Error('Plot layout parameter `width` must be a positive number');
        }

        // Backwards compatible check: there was previously a third option. Anything truthy should thus act as "responsive_resize: true"
        this.layout.responsive_resize = !!this.layout.responsive_resize;

        // Add panels
        this.layout.panels.forEach((panel_layout) => {
            this.addPanel(panel_layout);
        });
        return this;
    }

    /**
     * Set the dimensions for a plot, and ensure that panels are sized and positioned correctly.
     *
     * If dimensions are provided, resizes each panel proportionally to match the new plot dimensions. Otherwise,
     *   calculates the appropriate plot dimensions based on all panels, and ensures that panels are placed and
     *   rendered in the correct relative positions.
     * @private
     * @param {Number} [width] If provided and larger than minimum allowed size, set plot to this width
     * @param {Number} [height] If provided and larger than minimum allowed size, set plot to this height
     * @returns {Plot}
     * @fires event:layout_changed
     */
    setDimensions(width, height) {
        // If width and height arguments were passed, then adjust plot dimensions to fit all panels
        // Then resize the plot and proportionally resize panels to fit inside the new plot dimensions.
        if (!isNaN(width) && width >= 0 && !isNaN(height) && height >= 0) {
            // Resize operations may ask for a different amount of space than that used by panels.
            const height_scaling_factor = height / this._total_height;

            this.layout.width = Math.max(Math.round(+width), this.layout.min_width);
            // Override discrete values if resizing responsively
            if (this.layout.responsive_resize) {
                // All resize modes will affect width
                if (this.svg) {
                    this.layout.width = Math.max(this.svg.node().parentNode.getBoundingClientRect().width, this.layout.min_width);
                }
            }
            // Resize/reposition panels to fit, update proportional origins if necessary
            let y_offset = 0;
            this._panel_ids_by_y_index.forEach((panel_id) => {
                const panel = this.panels[panel_id];
                const panel_width = this.layout.width;
                // In this block, we are passing explicit dimensions that might require rescaling all panels at once
                const panel_height = panel.layout.height * height_scaling_factor;
                panel.setDimensions(panel_width, panel_height);
                panel.setOrigin(0, y_offset);
                y_offset += panel_height;
                panel.toolbar.update();
            });
        }

        // Set the plot height to the sum of all panels (using the "real" height values accounting for panel.min_height)
        const final_height = this._total_height;

        // Apply layout width and height as discrete values or viewbox values
        if (this.svg !== null) {
            // The viewBox must always be specified in order for "save as image" button to work
            this.svg.attr('viewBox', `0 0 ${this.layout.width} ${final_height}`);

            this.svg
                .attr('width', this.layout.width)
                .attr('height', final_height);
        }

        // If the plot has been initialized then trigger some necessary render functions
        if (this._initialized) {
            this._panel_boundaries.position();
            this.toolbar.update();
            this.curtain.update();
            this.loader.update();
        }

        return this.emit('layout_changed');
    }

    /**
     * Automatically position panels based on panel positioning rules and values.
     * Keep panels from overlapping vertically by adjusting origins, and keep the sum of proportional heights at 1.
     *
     * LocusZoom panels can only be stacked vertically (not horizontally)
     * @private
     */
    positionPanels() {
        // We want to enforce that all x-linked panels have consistent horizontal margins
        // (to ensure that aligned items stay aligned despite inconsistent initial layout parameters)
        // NOTE: This assumes panels have consistent widths already. That should probably be enforced too!
        const x_linked_margins = { left: 0, right: 0 };

        // Proportional heights for newly added panels default to null unless explicitly set, so determine appropriate
        // proportional heights for all panels with a null value from discretely set dimensions.
        // Likewise handle default nulls for proportional widths, but instead just force a value of 1 (full width)
        for (let panel of Object.values(this.panels)) {
            if (panel.layout.interaction.x_linked) {
                x_linked_margins.left = Math.max(x_linked_margins.left, panel.layout.margin.left);
                x_linked_margins.right = Math.max(x_linked_margins.right, panel.layout.margin.right);
            }
        }

        // Update origins on all panels without changing plot-level dimensions yet
        // Also apply x-linked margins to x-linked panels, updating widths as needed
        let y_offset = 0;
        this._panel_ids_by_y_index.forEach((panel_id) => {
            const panel = this.panels[panel_id];
            const panel_layout = panel.layout;
            panel.setOrigin(0, y_offset);
            y_offset += this.panels[panel_id].layout.height;
            if (panel_layout.interaction.x_linked) {
                const delta = Math.max(x_linked_margins.left - panel_layout.margin.left, 0)
                    + Math.max(x_linked_margins.right - panel_layout.margin.right, 0);
                panel_layout.width += delta;
                panel_layout.margin.left = x_linked_margins.left;
                panel_layout.margin.right = x_linked_margins.right;
                panel_layout.cliparea.origin.x = x_linked_margins.left;
            }
        });

        // Update dimensions on the plot to accommodate repositioned panels (eg when resizing one panel,
        //  also must update the plot dimensions)
        this.setDimensions();

        // Set dimensions on all panels using newly set plot-level dimensions and panel-level proportional dimensions
        this._panel_ids_by_y_index.forEach((panel_id) => {
            const panel = this.panels[panel_id];
            panel.setDimensions(
                this.layout.width,
                panel.layout.height,
            );
        });

        return this;
    }

    /**
     * Prepare the first rendering of the plot. This includes initializing the individual panels, but also creates shared
     *   elements such as mouse events, panel guides/boundaries, and loader/curtain.
     * @private
     * @returns {Plot}
     */
    initialize() {
        // Ensure proper responsive class is present on the containing node if called for
        if (this.layout.responsive_resize) {
            d3.select(this.container).classed('lz-container-responsive', true);

            // If this is a responsive layout then set a namespaced/unique onresize event listener on the window
            const resize_listener = () => window.requestAnimationFrame(() => this.rescaleSVG());

            window.addEventListener('resize', resize_listener);
            this.trackExternalListener(window, 'resize', resize_listener);

            // Many libraries collapse/hide tab widgets using display:none, which doesn't trigger the resize listener
            //   High threshold: Don't fire listeners on every 1px change, but allow this to work if the plot position is a bit cockeyed
            if (typeof IntersectionObserver !== 'undefined') { // don't do this in old browsers
                const options = { root: document.documentElement, threshold: 0.9 };
                const observer = new IntersectionObserver((entries, observer) => {
                    if (entries.some((entry) => entry.intersectionRatio > 0)) {
                        this.rescaleSVG();
                    }
                }, options);
                // IntersectionObservers will be cleaned up when DOM node removed; no need to track them for manual cleanup
                observer.observe(this.container);
            }

            // Forcing one additional setDimensions() call after the page is loaded clears up
            // any disagreements between the initial layout and the loaded responsive container's size
            const load_listener = () => this.setDimensions();
            window.addEventListener('load', load_listener);
            this.trackExternalListener(window, 'load', load_listener);
        }

        // Create an element/layer for containing mouse guides
        if (this.layout.mouse_guide) {
            const mouse_guide_svg = this.svg.append('g')
                .attr('class', 'lz-mouse_guide')
                .attr('id', `${this.id}.mouse_guide`);
            const mouse_guide_vertical_svg = mouse_guide_svg.append('rect')
                .attr('class', 'lz-mouse_guide-vertical')
                .attr('x', -1);
            const mouse_guide_horizontal_svg = mouse_guide_svg.append('rect')
                .attr('class', 'lz-mouse_guide-horizontal')
                .attr('y', -1);
            this._mouse_guide = {
                svg: mouse_guide_svg,
                vertical: mouse_guide_vertical_svg,
                horizontal: mouse_guide_horizontal_svg,
            };
        }

        // Add curtain and loader prototpyes to the plot
        this.curtain = generateCurtain.call(this);
        this.loader = generateLoader.call(this);

        // Create the panel_boundaries object with show/position/hide methods
        this._panel_boundaries = {
            parent: this,
            hide_timeout: null,
            showing: false,
            dragging: false,
            selectors: [],
            corner_selector: null,
            show: function() {
                // Generate panel boundaries
                if (!this.showing && !this.parent.curtain.showing) {
                    this.showing = true;
                    // Loop through all panels to create a horizontal boundary for each
                    this.parent._panel_ids_by_y_index.forEach((panel_id, panel_idx) => {
                        const selector = d3.select(this.parent.svg.node().parentNode).insert('div', '.lz-data_layer-tooltip')
                            .attr('class', 'lz-panel-boundary')
                            .attr('title', 'Resize panel');
                        selector.append('span');
                        const panel_resize_drag = d3.drag();
                        panel_resize_drag.on('start', () => {
                            this.dragging = true;
                        });
                        panel_resize_drag.on('end', () => {
                            this.dragging = false;
                        });
                        panel_resize_drag.on('drag', () => {
                            // First set the dimensions on the panel we're resizing
                            const this_panel = this.parent.panels[this.parent._panel_ids_by_y_index[panel_idx]];
                            const original_panel_height = this_panel.layout.height;
                            this_panel.setDimensions(this.parent.layout.width, this_panel.layout.height + d3.event.dy);
                            const panel_height_change = this_panel.layout.height - original_panel_height;
                            // Next loop through all panels.
                            // Update proportional dimensions for all panels including the one we've resized using discrete heights.
                            // Reposition panels with a greater y-index than this panel to their appropriate new origin.
                            this.parent._panel_ids_by_y_index.forEach((loop_panel_id, loop_panel_idx) => {
                                const loop_panel = this.parent.panels[this.parent._panel_ids_by_y_index[loop_panel_idx]];
                                if (loop_panel_idx > panel_idx) {
                                    loop_panel.setOrigin(loop_panel.layout.origin.x, loop_panel.layout.origin.y + panel_height_change);
                                    loop_panel.toolbar.position();
                                }
                            });
                            // Reset dimensions on the entire plot and reposition panel boundaries
                            this.parent.positionPanels();
                            this.position();
                        });
                        selector.call(panel_resize_drag);
                        this.parent._panel_boundaries.selectors.push(selector);
                    });
                    // Create a corner boundary / resize element on the bottom-most panel that resizes the entire plot
                    const corner_selector = d3.select(this.parent.svg.node().parentNode)
                        .insert('div', '.lz-data_layer-tooltip')
                        .attr('class', 'lz-panel-corner-boundary')
                        .attr('title', 'Resize plot');

                    corner_selector
                        .append('span')
                        .attr('class', 'lz-panel-corner-boundary-outer');
                    corner_selector
                        .append('span')
                        .attr('class', 'lz-panel-corner-boundary-inner');

                    const corner_drag = d3.drag();
                    corner_drag.on('start', () => {
                        this.dragging = true;
                    });
                    corner_drag.on('end', () => {
                        this.dragging = false;
                    });
                    corner_drag.on('drag', () => {
                        this.parent.setDimensions(this.parent.layout.width + d3.event.dx, this.parent._total_height + d3.event.dy);
                    });
                    corner_selector.call(corner_drag);
                    this.parent._panel_boundaries.corner_selector = corner_selector;
                }
                return this.position();
            },
            position: function() {
                if (!this.showing) {
                    return this;
                }
                // Position panel boundaries
                const plot_page_origin = this.parent._getPageOrigin();
                this.selectors.forEach((selector, panel_idx) => {
                    const panel = this.parent.panels[this.parent._panel_ids_by_y_index[panel_idx]];
                    const panel_page_origin = panel._getPageOrigin();
                    const left = plot_page_origin.x;
                    const top = panel_page_origin.y + panel.layout.height - 12;
                    const width = this.parent.layout.width - 1;
                    selector
                        .style('top', `${top}px`)
                        .style('left', `${left}px`)
                        .style('width', `${width}px`);
                    selector.select('span')
                        .style('width', `${width}px`);
                });
                // Position corner selector
                const corner_padding = 10;
                const corner_size = 16;
                this.corner_selector
                    .style('top', `${plot_page_origin.y + this.parent._total_height - corner_padding - corner_size}px`)
                    .style('left', `${plot_page_origin.x + this.parent.layout.width - corner_padding - corner_size}px`);
                return this;
            },
            hide: function() {
                if (!this.showing) {
                    return this;
                }
                this.showing = false;
                // Remove panel boundaries
                this.selectors.forEach((selector) => {
                    selector.remove();
                });
                this.selectors = [];
                // Remove corner boundary
                this.corner_selector.remove();
                this.corner_selector = null;
                return this;
            },
        };

        // Show panel boundaries stipulated by the layout (basic toggle, only show on mouse over plot)
        if (this.layout.panel_boundaries) {
            d3.select(this.svg.node().parentNode)
                .on(`mouseover.${this.id}.panel_boundaries`, () => {
                    clearTimeout(this._panel_boundaries.hide_timeout);
                    this._panel_boundaries.show();
                })
                .on(`mouseout.${this.id}.panel_boundaries`, () => {
                    this._panel_boundaries.hide_timeout = setTimeout(() => {
                        this._panel_boundaries.hide();
                    }, 300);
                });
        }

        // Create the toolbar object and immediately show it
        this.toolbar = new Toolbar(this).show();

        // Initialize all panels
        for (let id in this.panels) {
            this.panels[id].initialize();
        }

        // Define plot-level mouse events
        const namespace = `.${this.id}`;
        if (this.layout.mouse_guide) {
            const mouseout_mouse_guide = () => {
                this._mouse_guide.vertical.attr('x', -1);
                this._mouse_guide.horizontal.attr('y', -1);
            };
            const mousemove_mouse_guide = () => {
                const coords = d3.mouse(this.svg.node());
                this._mouse_guide.vertical.attr('x', coords[0]);
                this._mouse_guide.horizontal.attr('y', coords[1]);
            };
            this.svg
                .on(`mouseout${namespace}-mouse_guide`, mouseout_mouse_guide)
                .on(`touchleave${namespace}-mouse_guide`, mouseout_mouse_guide)
                .on(`mousemove${namespace}-mouse_guide`, mousemove_mouse_guide);
        }
        const mouseup = () => {
            this.stopDrag();
        };
        const mousemove = () => {
            const { _interaction } = this;
            if (_interaction.dragging) {
                const coords = d3.mouse(this.svg.node());
                if (d3.event) {
                    d3.event.preventDefault();
                }
                _interaction.dragging.dragged_x = coords[0] - _interaction.dragging.start_x;
                _interaction.dragging.dragged_y = coords[1] - _interaction.dragging.start_y;
                this.panels[_interaction.panel_id].render();
                _interaction.linked_panel_ids.forEach((panel_id) => {
                    this.panels[panel_id].render();
                });
            }
        };
        this.svg
            .on(`mouseup${namespace}`, mouseup)
            .on(`touchend${namespace}`, mouseup)
            .on(`mousemove${namespace}`, mousemove)
            .on(`touchmove${namespace}`, mousemove);

        // Add an extra namespaced mouseup handler to the containing body, if there is one
        // This helps to stop interaction events gracefully when dragging outside of the plot element
        const body_selector = d3.select('body');
        const body_node = body_selector.node();
        if (body_node) {
            body_node.addEventListener('mouseup', mouseup);
            body_node.addEventListener('touchend', mouseup);

            this.trackExternalListener(body_node, 'mouseup', mouseup);
            this.trackExternalListener(body_node, 'touchend', mouseup);
        }

        this.on('match_requested', (eventData) => {
            // Layers can broadcast that a specific point has been selected, and the plot will tell every other layer
            //  to look for that value. Whenever a point is de-selected, it clears the match.
            const data = eventData.data;
            const to_send = (data.active ? data.value : null);
            const emitted_by = eventData.target.id;
            // When a match is initiated, hide all tooltips from other panels (prevents zombie tooltips from reopening)
            // TODO: This is a bit hacky. Right now, selection and matching are tightly coupled, and hence tooltips
            //   reappear somewhat aggressively. A better solution depends on designing alternative behavior, and
            //   applying tooltips post (instead of pre) render.
            Object.values(this.panels).forEach((panel) => {
                if (panel.id !== emitted_by) {
                    Object.values(panel.data_layers).forEach((layer) => layer.destroyAllTooltips(false));
                }
            });

            this.applyState({ lz_match_value: to_send });
        });

        this._initialized = true;

        // An extra call to setDimensions with existing discrete dimensions fixes some rounding errors with tooltip
        // positioning. TODO: make this additional call unnecessary.
        const client_rect = this.svg.node().getBoundingClientRect();
        const width = client_rect.width ? client_rect.width : this.layout.width;
        const height = client_rect.height ? client_rect.height : this._total_height;
        this.setDimensions(width, height);

        return this;
    }

    /**
     * Register interactions along the specified axis, provided that the target panel allows interaction.
     * @private
     * @param {Panel} panel
     * @param {('background'|'x_tick'|'y1_tick'|'y2_tick')} method The direction (axis) along which dragging is being performed.
     * @returns {Plot}
     */
    startDrag(panel, method) {
        panel = panel || null;
        method = method || null;

        let axis = null;
        switch (method) {
        case 'background':
        case 'x_tick':
            axis = 'x';
            break;
        case 'y1_tick':
            axis = 'y1';
            break;
        case 'y2_tick':
            axis = 'y2';
            break;
        }

        if (!(panel instanceof Panel) || !axis || !this._canInteract()) {
            return this.stopDrag();
        }

        const coords = d3.mouse(this.svg.node());
        this._interaction = {
            panel_id: panel.id,
            linked_panel_ids: panel.getLinkedPanelIds(axis),
            dragging: {
                method: method,
                start_x: coords[0],
                start_y: coords[1],
                dragged_x: 0,
                dragged_y: 0,
                axis: axis,
            },
        };

        this.svg.style('cursor', 'all-scroll');

        return this;
    }

    /**
     * Process drag interactions across the target panel and synchronize plot state across other panels in sync;
     *   clear the event when complete
     * @private
     * @returns {Plot}
     */
    stopDrag() {
        const { _interaction } = this;
        if (!_interaction.dragging) {
            return this;
        }

        if (typeof this.panels[_interaction.panel_id] != 'object') {
            this._interaction = {};
            return this;
        }
        const panel = this.panels[_interaction.panel_id];

        // Helper function to find the appropriate axis layouts on child data layers
        // Once found, apply the extent as floor/ceiling and remove all other directives
        // This forces all associated axes to conform to the extent generated by a drag action
        const overrideAxisLayout = (axis, axis_number, extent) => {
            panel._data_layer_ids_by_z_index.forEach((id) => {
                const axis_layout = panel.data_layers[id].layout[`${axis}_axis`];
                if (axis_layout.axis === axis_number) {
                    axis_layout.floor = extent[0];
                    axis_layout.ceiling = extent[1];
                    delete axis_layout.lower_buffer;
                    delete axis_layout.upper_buffer;
                    delete axis_layout.min_extent;
                    delete axis_layout.ticks;
                }
            });
        };

        switch (_interaction.dragging.method) {
        case 'background':
        case 'x_tick':
            if (_interaction.dragging.dragged_x !== 0) {
                overrideAxisLayout('x', 1, panel.x_extent);
                this.applyState({ start: panel.x_extent[0], end: panel.x_extent[1] });
            }
            break;
        case 'y1_tick':
        case 'y2_tick':
            if (_interaction.dragging.dragged_y !== 0) {
                const y_axis_number = parseInt(_interaction.dragging.method[1]);
                overrideAxisLayout('y', y_axis_number, panel[`y${y_axis_number}_extent`]);
            }
            break;
        }

        this._interaction = {};
        this.svg.style('cursor', null);

        return this;

    }

    get _total_height() {
        // The plot height is a calculated property, derived from the sum of its panel layout objects
        return this.layout.panels.reduce((acc, item) => item.height + acc, 0);
    }
}

export {Plot as default};

// Only for testing
export { _updateStatePosition };