# Source: components/data_layer/base.js

    /**
     * Data layers represent instructions for how to render common types of information.
     *  (GWAS scatter plot, nearby genes, straight lines and filled curves, etc)
     *
     * Each rendering type also provides helpful functionality such as filtering, matching, and interactive tooltip
     *  display. Predefined layers can be extended or customized, with many configurable options.
     *
     * @module LocusZoom_DataLayers
     */

    import * as d3 from 'd3';

    import {STATUSES} from '../constants';
    import Field from '../../data/field';
    import {parseFields} from '../../helpers/display';
    import {deepCopy, findFields, merge} from '../../helpers/layouts';
    import MATCHERS from '../../registry/matchers';
    import SCALABLE from '../../registry/scalable';

    /**
     * "Scalable" parameters indicate that a datum can be rendered in custom ways based on its value. (color, size, shape, etc)
     *
     * This means that if the value of this property is a scalar, it is used directly (`color: '#FF0000'`). But if the
     *  value is an array of options, each will be evaluated in turn until the first non-null result is found. The syntax
     *  below describes how each member of the array should specify the field and scale function to be used.
     * Often, the last item in the list is a string, providing a "default" value if all scale functions evaluate to null.
     *
     * @typedef {object[]|string} ScalableParameter
     * @property {string} [field] The name of the field to use in the scale function. If omitted, all fields for the given
     *  datum element will be passed to the scale function.
     * @property {module:LocusZoom_ScaleFunctions} scale_function The name of a scale function that will be run on each individual datum
     * @property {object} parameters A set of parameters that configure the desired scale function (options vary by function)
     */

    /**
     * @typedef {Object} module:LocusZoom_DataLayers~behavior
     * @property {'set'|'unset'|'toggle'|'link'} action
     * @property {'highlighted'|'selected'|'faded'|'hidden'} status An element display status to set/unset/toggle
     * @property {boolean} exclusive Whether an element status should be exclusive (eg only allow one point to be selected at a time)
     * @property {string} href For links, the URL to visit when clicking
     * @property {string} target For links, the `target` attribute (eg, name of a window or tab in which to open this link)
     */

    /**
     * @typedef {object} FilterOption
     * @property {string} field The name of a field found within each datapoint datum
     * @property {module:LocusZoom_MatchFunctions} operator The name of a comparison function to use when deciding if the
     *   field satisfies this filter
     * @property value The target value to compare to
     */

    /**
     * @typedef {object} DataOperation A synchronous function that modifies data returned from adapters, in order to clean up or reformat prior to plotting.
     * @property {module:LocusZoom_DataFunctions|'fetch'} type
     * @property {String[]} [from] For operations of type "fetch", this is required. By default, it will fill in any items provided in "namespace" (everything specified in namespace triggers an adapter/network request)
     *   A namespace should be manually specified in this array when there are dependencies (one request depends on the content of what is returned from another namespace).
     *   Eg, for ld to be fetched after association data, specify "ld(assoc)". Most LocusZoom examples fill in all items, in order to make the examples more clear.
     * @property {String} [name] The name of this operation. This only needs to be specified if a data layer performs several operations, and needs to refer to the result of a prior operation.
     *   Eg, if the retrieved data is combined via several left joins in series: `name: "assoc_plus_ld"` -> feeds into `{name: "final", requires: ["assoc_plus_ld"]}`
     * @property {String[]} requires The names of each adapter required. This does not need to specify dependencies, just
     *  the names of other namespaces (or data operations) whose results must be available before a join can be performed
     * @property {String[]} params Any user-defined parameters that should be passed to the particular join function
     *   (see: {@link module:LocusZoom_DataFunctions} for details). For example, this could specify the left and right key fields for a join function, based on the expected field names used in this data layer: `params: ['assoc:position', 'ld:position2']`
     *   Separate from this section, data functions will also receive a copy of "plot.state" automatically.
     */

    /**
     * @typedef {object} LegendItem
     * @property [shape] This is optional (e.g. a legend element could just be a textual label).
     *   Supported values are the standard d3 3.x symbol types (i.e. "circle", "cross", "diamond", "square",
     *   "triangle-down", and "triangle-up"), as well as "rect" for an arbitrary square/rectangle or "line" for a path.
     *   A special "ribbon" option can be use to draw a series of explicit, numeric-only color stops (a row of colored squares, such as to indicate LD)
     * @property {string} color The point color (hexadecimal, rgb, etc)
     * @property {string} label The human-readable label of the legend item
     * @property {string} [class] The name of a CSS class used to style the point in the legend
     * @property {number} [size] The point area for each element (if the shape is a d3 symbol). Eg, for a 40 px area,
     *  a circle would be ~7..14 px in diameter.
     * @property {number} [length] Length (in pixels) for the path rendered as the graphical portion of the legend element
     *  if the value of the shape parameter is "line".
     * @property {number} [width] Width (in pixels) for the rect rendered as the graphical portion of the legend element if
     *   the value of the shape parameter is "rect" or "ribbon".
     * @property {number} [height] Height (in pixels) for the rect rendered as the graphical portion of the legend element if
     *   the value of the shape parameter is "rect" or "ribbon".
     * @property {'vertical'|'horizontal'} [orientation='vertical'] For shape "ribbon", specifies whether to draw the ribbon vertically or horizontally.
     * @property {Array} [tick_labels] For shape "ribbon", specifies the tick labels that correspond to each colorstop value. Tick labels appear at all box edges: this array should have 1 more item than the number of colorstops.
     * @property {String[]} [color_stops] For shape "ribbon", specifies the colors of each box in the row of colored squares. There should be 1 fewer item in color_stops than the number of tick labels.
     * @property {object} style CSS styles object to be applied to the DOM element representing the graphical portion of
     *   the legend element.
     */

    /**
     * A basic description of keys expected in all data layer layouts. Not intended to be directly used or modified by an end user.
     * @memberof module:LocusZoom_DataLayers~BaseDataLayer
     * @protected
     */
    const default_layout = {
        id: '',
        type: '',
        tag: 'custom_data_type',
        namespace: {},
        data_operations: [],
        id_field: 'id',
        filters: null,
        match: {},
        x_axis: {},
        y_axis: {},  // Axis options vary based on data layer type
        legend: null,
        tooltip: {},
        tooltip_positioning: 'horizontal',  // Where to draw tooltips relative to the point. Can be "vertical" or "horizontal"
        behaviors: {},
    };

    /**
     * A data layer is an abstract class representing a data set and its graphical representation within a panel
     * @public
    */
    class BaseDataLayer {
        /**
         * @param {string} [layout.id=''] An identifier string that must be unique across all layers within the same panel
         * @param {string} [layout.type=''] The type of data layer. This parameter is used in layouts to specify which class
         *   (from the registry) is created; it is also used in CSS class names.
         * @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 data
         *   layer that shows association scatter plots, anywhere": even if the IDs are different, the tag can be the same.
         *   Most built-in data layers will contain a tag that describes, in human-readable terms, what kind of data is being shown.
         *   (see: {@link LayoutRegistry.mutate_attrs})
         * @param {string} [layout.id_field] The datum field used for unique element IDs when addressing DOM elements, mouse
         *   events, etc. This should be unique to the specified datum, and persistent across re-renders (because it is
         *   used to identify where to draw tooltips, eg, if the plot is dragged or zoomed). If no single field uniquely
         *   identifies all items, a template expression can be used to create an ID from multiple fields instead. (it is
         *   your job to assure that all of the expected fields are present in every element)
         * @param {object} layout.namespace A set of key value pairs representing how to map the local usage of data ("assoc")
         *   to the globally unique name for something defined in LocusZoom.DataSources.
         *   Namespaces allow a single layout to be reused to plot many tracks of the same type: "given some form of association data, plot it".
         *   These pairs take the form of { local_name: global_name }. (and all data layer layouts provide a default) In order to reuse
         *   a layout with a new provider of data- like plotting two association studies stacked together-
         *   only the namespace section of the layout needs to be overridden.
         *   Eg, `LocusZoom.Layouts.get('data_layers', 'association_pvalues', { namespace: { assoc: 'assoc_study_2' }})`
         * @param {module:LocusZoom_DataLayers~DataOperation[]} layout.data_operations A set of data operations that will be performed on the data returned from the data adapters specified in `namespace`. (see: {@link module:LocusZoom_DataFunctions})
         * @param {module:LocusZoom_DataLayers~FilterOption[]} [layout.filters] If present, restricts the list of data elements to be displayed. Typically, filters
         *  hide elements, but arrange the layer so as to leave the space those elements would have occupied. The exact
         *  details vary from one layer to the next. See the Interactivity Tutorial for details.
         * @param {object} [layout.match] An object describing how to connect this data layer to other data layers in the
         *   same plot. Specifies keys `send` and `receive` containing the names of fields with data to be matched;
         *   `operator` specifies the name of a MatchFunction to use. If a datum matches the broadcast value, it will be
         *   marked with the special field `lz_is_match=true`, which can be used in any scalable layout directive to control how the item is rendered.
         * @param {boolean} [layout.x_axis.decoupled=false] If true, the data in this layer will not influence the x-extent of the panel.
         * @param {'state'|null} [layout.x_axis.extent] If provided, the region plot x-extent will be determined from
         *   `plot.state` rather than from the range of the data. This is the most common way of setting x-extent,
         *   as it is useful for drawing a set of panels to reflect a particular genomic region.
         * @param {number} [layout.x_axis.floor] The low end of the x-extent, which overrides any actual data range, min_extent, or buffer options.
         * @param {number} [layout.x_axis.ceiling] The high end of the x-extent, which overrides any actual data range, min_extent, or buffer options.
         * @param {Number[]} [layout.x_axis.min_extent] The smallest possible range [min, max] of the x-axis. If the actual values lie outside the extent, the actual data takes precedence.
         * @param {number} [layout.x_axis.field] The datum field to look at when determining data extent along the x-axis.
         * @param {number} [layout.x_axis.lower_buffer] Amount to expand (pad) the lower end of an axis as a proportion of the extent of the data.
         * @param {number} [layout.x_axis.upper_buffer] Amount to expand (pad) the higher end of an axis as a proportion of the extent of the data.
         * @param {boolean} [layout.y_axis.decoupled=false] If true, the data in this layer will not influence the y-extent of the panel.
         * @param {object} [layout.y_axis.axis=1] Which y axis to use for this data layer (left=1, right=2)
         * @param {number} [layout.y_axis.floor] The low end of the y-extent, which overrides any actual data range, min_extent, or buffer options.
         * @param {number} [layout.y_axis.ceiling] The high end of the y-extent, which overrides any actual data range, min_extent, or buffer options.
         * @param {Number[]} [layout.y_axis.min_extent] The smallest possible range [min, max] of the y-axis. Actual lower or higher data values will take precedence.
         * @param {number} [layout.y_axis.field] The datum field to look at when determining data extent along the y-axis.
         * @param {number} [layout.y_axis.lower_buffer] Amount to expand (pad) the lower end of an axis as a proportion of the extent of the data.
         * @param {number} [layout.y_axis.upper_buffer] Amount to expand (pad) the higher end of an axis as a proportion of the extent of the data.
         * @param {object} [layout.tooltip.show] Define when to show a tooltip in terms of interaction states, eg, `{ or: ['highlighted', 'selected'] }`
         * @param {object} [layout.tooltip.hide] Define when to hide a tooltip in terms of interaction states, eg, `{ and: ['unhighlighted', 'unselected'] }`
         * @param {boolean} [layout.tooltip.closable] Whether a tool tip should render a "close" button in the upper right corner.
         * @param {string} [layout.tooltip.html] HTML template to render inside the tool tip. The template syntax uses curly braces to allow simple expressions:
         *   eg `{{sourcename:fieldname}} to insert a field value from the datum associated with
         *   the tooltip/element. Conditional tags are supported using the format:
         *   `{{#if sourcename:fieldname|transforms_can_be_used_too}}render text here{{#else}}Optional else branch{{/if}}`.
         * @param {'horizontal'|'vertical'|'top'|'bottom'|'left'|'right'} [layout.tooltip_positioning='horizontal'] Where to draw the tooltip relative to the datum.
         *  Typically tooltip positions are centered around the midpoint of the data element, subject to overflow off the edge of the plot.
         * @param {object} [layout.behaviors] LocusZoom data layers support the binding of mouse events to one or more
         *   layout-definable behaviors. Some examples of behaviors include highlighting an element on mouseover, or
         *   linking to a dynamic URL on click, etc.
         * @param {module:LocusZoom_DataLayers~LegendItem[]} [layout.legend] Tick marks found in the panel legend
         * @param {module:LocusZoom_DataLayers~behavior[]} [layout.behaviors.onclick]
         * @param {module:LocusZoom_DataLayers~behavior[]} [layout.behaviors.onctrlclick]
         * @param {module:LocusZoom_DataLayers~behavior[]} [layout.behaviors.onctrlshiftclick]
         * @param {module:LocusZoom_DataLayers~behavior[]} [layout.behaviors.onshiftclick]
         * @param {module:LocusZoom_DataLayers~behavior[]} [layout.behaviors.onmouseover]
         * @param {module:LocusZoom_DataLayers~behavior[]} [layout.behaviors.onmouseout]
         * @param {Panel|null} parent Where this layout is used
         */
        constructor(layout, parent) {
            /**
             * @private
             * @member {Boolean}
             */
            this._initialized = false;
            /**
             * @private
             * @member {Number}
             */
            this._layout_idx = null;

            /**
             * The unique identifier for this layer. Should be unique within this panel.
             * @public
             * @member {String}
             */
            this.id     = null;

            /**
             * The fully qualified identifier for the data layer, prefixed by any parent or container elements.
             * @type {string}
             * @private
             */
            this._base_id = null;

            /**
             * @protected
             * @member {Panel}
             */
            this.parent = parent || null;
            /**
             * @private
             * @member {{group: d3.selection, container: d3.selection, clipRect: d3.selection}}
             */
            this.svg    = {};

            /**
             * @protected
             * @member {Plot}
             */
            this.parent_plot = null;
            if (parent) {
                this.parent_plot = parent.parent;
            }

            /**
             * The current layout configuration for this data layer. This reflects any resizing or dynamically generated
             *  config options produced during rendering. Direct layout mutations are a powerful way to dynamically
             *  modify the plot in response to user interactions, but require a deep knowledge of LZ internals to use
             *  effectively.
             * @public
             * @member {Object}
             */
            this.layout = merge(layout || {}, default_layout);
            if (this.layout.id) {
                this.id = this.layout.id;
            }

            /**
             * A user-provided function used to filter data for display. If provided, this will override any declarative
             *  options in `layout.filters`
             * @private
             * @deprecated
             */
            this._filter_func = null;

            // Ensure any axes defined in the layout have an explicit axis number (default: 1)
            if (this.layout.x_axis !== {} && typeof this.layout.x_axis.axis !== 'number') {
                // TODO: Example of x2? if none remove
                this.layout.x_axis.axis = 1;
            }
            if (this.layout.y_axis !== {} && typeof this.layout.y_axis.axis !== 'number') {
                this.layout.y_axis.axis = 1;
            }

            /**
             * Values in the layout object may change during rendering etc. Retain a copy of the original data layer state.
             * 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);

            /**
             * @private
             * @member {Object}
             */
            this.state = {};
            /**
             * @private
             * @member {String}
             */
            this._state_id = null;

            /**
             * @private
             * @member {Object}
             * */
            this._layer_state = null;
            // Create a default state (and set any references to the parent as appropriate)
            this._setDefaultState();

            // Initialize parameters for storing data and tool tips
            /**
             * The data retrieved from a region request. This field is useful for debugging, but will be overridden on
             *  re-render; do not modify it directly. The point annotation cache can be used to preserve markings
             *  after re-render.
             * @protected
             * @member {Array}
             */
            this.data = [];
            if (this.layout.tooltip) {
                /**
                 * @private
                 * @member {Object}
                 */
                this._tooltips = {};
            }

            // Initialize flags for tracking global statuses
            this._global_statuses = {
                'highlighted': false,
                'selected': false,
                'faded': false,
                'hidden': false,
            };

            // On first load, pre-parse the data specification once, so that it can be used for all other data retrieval
            this._data_contract = new Set(); // List of all fields requested by the layout
            this._entities = new Map();
            this._dependencies = [];
            this.mutateLayout();  // Parse data spec and any other changes that need to reflect the layout
        }

        /****** Public interface: methods for manipulating the layer from other parts of LZ */

        /**
         * @public
         */
        render() {
            throw new Error('Method must be implemented');
        }

        /**
         * Move a data layer forward relative to others by z-index
         * @public
         * @returns {BaseDataLayer}
         */
        moveForward() {
            const layer_order = this.parent._data_layer_ids_by_z_index;
            const current_index = this.layout.z_index;
            if (layer_order[current_index + 1]) {
                layer_order[current_index] = layer_order[current_index + 1];
                layer_order[current_index + 1] = this.id;
                this.parent.resortDataLayers();
            }
            return this;
        }

        /**
         * Move a data layer back relative to others by z-index
         * @public
         * @returns {BaseDataLayer}
         */
        moveBack() {
            const layer_order = this.parent._data_layer_ids_by_z_index;
            const current_index = this.layout.z_index;
            if (layer_order[current_index - 1]) {
                layer_order[current_index] = layer_order[current_index - 1];
                layer_order[current_index - 1] = this.id;
                this.parent.resortDataLayers();
            }
            return this;
        }

        /**
         * Set an "annotation": a piece of additional information about a point that is preserved across re-render,
         *  or as the user pans and zooms near this region.
         *
         * Annotations can be referenced as a named pseudo-field in any filters and scalable parameters. (template support
         *  may be added in the future)
         * Sample use case: user clicks a tooltip to "label this specific point". (or change any other display property)
         *
         * @public
         * @param {String|Object} element The data object or ID string for the element
         * @param {String} key The name of the annotation to track
         * @param {*} value The value of the marked field
         */
        setElementAnnotation (element, key, value) {
            const id = this.getElementId(element);
            if (!this._layer_state.extra_fields[id]) {
                this._layer_state.extra_fields[id] = {};
            }
            this._layer_state.extra_fields[id][key] = value;
            return this;
        }

        /**
         * Select a filter function to be applied to the data. DEPRECATED: Please use the LocusZoom.MatchFunctions registry
         *  and reference via declarative filters.
         * @param func
         * @deprecated
         */
        setFilter(func) {
            console.warn('The setFilter method is deprecated and will be removed in the future; please use the layout API with a custom filter function instead');
            this._filter_func = func;
        }

        /**
         * A list of operations that should be run when the layout is mutated
         * Typically, these are things done once when a layout is first specified, that would not automatically
         *  update when the layout was changed.
         * @public
         */
        mutateLayout() {
            // Are we fetching data from external providers? If so, validate that those API calls would meet the expected contract.
            if (this.parent_plot) { // Don't run this method if instance isn't mounted to a plot, eg unit tests that don't require requester
                const { namespace, data_operations } = this.layout;
                this._data_contract = findFields(this.layout, Object.keys(namespace));
                const [entities, dependencies] = this.parent_plot.lzd.config_to_sources(namespace, data_operations, this);
                this._entities = entities;
                this._dependencies = dependencies;
            }
        }

        /********** Protected methods: useful in subclasses to manipulate data layer behaviors */
        /**
         * Implementation hook for fetching the min and max values of available data. Used to determine axis range, if no other
         *   explicit axis settings override. Useful for data layers where the data extent depends on more than one field.
         *   (eg confidence intervals in a forest plot)
         *
         * @protected
         * @param data
         * @param axis_config The configuration object for the specified axis.
         * @returns {Array} [min, max] without any padding applied
         */
        _getDataExtent (data, axis_config) {
            data = data || this.data;
            // By default this depends only on a single field.
            return d3.extent(data, (d) => {
                const f = new Field(axis_config.field);
                return +f.resolve(d);
            });
        }

        /**
         * Fetch the fully qualified ID to be associated with a specific visual element, based on the data to which that
         *   element is bound. In general this element ID will be unique, allowing it to be addressed directly via selectors.
         *
         * The ID should also be stable across re-renders, so that tooltips and highlights may be reapplied to that
         *   element as we switch regions or drag left/right. If the element is not unique along a single field (eg PheWAS data),
         *   a unique ID can be generated via a template expression like `{{phewas:pheno}}-{{phewas:trait_label}}`
         * @protected
         * @param {Object} element The data associated with a particular element
         * @returns {String}
         */
        getElementId (element) {
            // Use a cached value if possible
            const id_key = Symbol.for('lzID');
            if (element[id_key]) {
                return element[id_key];
            }

            // Two ways to get element ID: field can specify an exact field name, or, we can parse a template expression
            const id_field = this.layout.id_field;
            let value  = element[id_field];
            if (typeof value === 'undefined' && /{{[^{}]*}}/.test(id_field)) {
                // No field value was found directly, but if it looks like a template expression, next, try parsing that
                // WARNING: In this mode, it doesn't validate that all requested fields from the template are present. Only use this if you trust the data being given to the plot!
                value = parseFields(id_field, element, {}); // Not allowed to use annotations b/c IDs should be stable, and annos may be transient
            }
            if (value === null || value === undefined) {
                // Neither exact field nor template options produced an ID
                throw new Error('Unable to generate element ID');
            }
            const element_id = value.toString().replace(/\W/g, '');

            // Cache ID value for future calls
            const key = (`${this.getBaseId()}-${element_id}`).replace(/([:.[\],])/g, '_');
            element[id_key] = key;
            return key;
        }

        /**
         * Abstract method. It should be overridden by data layers that implement separate status
         *   nodes, such as genes or intervals.
         * Fetch an ID that may bind a data element to a separate visual node for displaying status
         * Examples of this might be highlighting a gene with a surrounding box to show select/highlight statuses, or
         *   a group of unrelated intervals (all markings grouped within a category).
         * @private
         * @param {String|Object} element
         * @returns {String|null}
         */
        getElementStatusNodeId (element) {
            return null;
        }

        /**
         * Returns a reference to the underlying data associated with a single visual element in the data layer, as
         *   referenced by the unique identifier for the element
         *
         * @ignore
         * @protected
         * @param {String} id The unique identifier for the element, as defined by `getElementId`
         * @returns {Object|null} The data bound to that element
         */
        getElementById(id) {
            const selector = d3.select(`#${id.replace(/([:.[\],])/g, '\\$1')}`); // escape special characters
            if (!selector.empty() && selector.data() && selector.data().length) {
                return selector.data()[0];
            } else {
                return null;
            }
        }

        /**
         * Basic method to apply arbitrary methods and properties to data elements.
         *   This is called on all data immediately after being fetched. (requires reMap, not just re-render)
         *
         * Allowing a data element to access its parent enables interactive functionality, such as tooltips that modify
         *  the parent plot. This is also used for system-derived fields like "matching" behavior".
         *
         * @protected
         * @returns {BaseDataLayer}
         */
        applyDataMethods() {
            const field_to_match = (this.layout.match && this.layout.match.receive);
            const match_function = MATCHERS.get(this.layout.match && this.layout.match.operator || '=');
            const broadcast_value = this.parent_plot.state.lz_match_value;
            // Match functions are allowed to use transform syntax on field values, but not (yet) UI "annotations"
            const field_resolver = field_to_match ? new Field(field_to_match) : null;

            // Does the data from the API satisfy the list of fields expected by this layout?
            //   Not every record will have every possible field (example: left joins like assoc + ld). The check is "did
            //      we see this field at least once in any record at all".
            if (this.data.length && this._data_contract.size) {
                const fields_unseen = new Set(this._data_contract);
                for (let record of this.data) {
                    Object.keys(record).forEach((field) => fields_unseen.delete(field));
                    if (!fields_unseen.size) {
                        // Once every requested field has been seen in at least one record, no need to look at more records
                        break;
                    }
                }
                if (fields_unseen.size) {
                    // Current implementation is a soft warning, so that certain "incremental enhancement" features
                    //  (like rsIDs in tooltips) can fail gracefully if the API does not provide the requested info.
                    //  Template syntax like `{{#if fieldname}}` means that throwing an Error is not always the right answer for missing data.
                    console.debug(`Data layer '${this.getBaseId()}' did not receive all expected fields from retrieved data. Missing fields are: ${[...fields_unseen]}
      Common reasons for this error include API payloads with missing fields, or data layer layouts that use two kinds of data and need join logic in "data_operations"
      If this field is optional, you can safely ignore this message. Examples include {{#if value}} tags or conditional color rules.
    `);
                }
            }

            this.data.forEach((item, i) => {
                // Basic toHTML() method - return the stringified value in the id_field, if defined.

                // When this layer receives data, mark whether points match (via a synthetic boolean field)
                //   Any field-based layout directives (color, size, shape) can then be used to control display
                if (field_to_match && broadcast_value !== null && broadcast_value !== undefined) {
                    item.lz_is_match = match_function(field_resolver.resolve(item), broadcast_value);
                }

                // Helper methods - return a reference to various plot levels. Useful for interactive tooltips.
                item.getDataLayer = () => this;
                item.getPanel = () => this.parent || null;
                item.getPlot = () => {
                    // For unit testing etc, this layer may be created without a parent.
                    const panel = this.parent;
                    return panel ? panel.parent : null;
                };
            });
            this.applyCustomDataMethods();
            return this;
        }

        /**
         * Hook that allows custom datalayers to apply additional methods and properties to data elements as needed.
         * Most data layers will never need to use this.
         * @protected
         * @returns {BaseDataLayer}
         */
        applyCustomDataMethods() {
            return this;
        }

        /**
         * Apply scaling functions to an element as needed, based on the layout rules governing display + the element's data
         * If the layout parameter is already a primitive type, simply return the value as given
         *
         * In the future this may be further expanded, so that scaling functions can operate similar to mappers
         *  (item, index, array). Additional arguments would be added as the need arose.
         *
         * @private
         * @param {Array|Number|String|Object} option_layout Either a scalar ("color is red") or a configuration object
         *  ("rules for how to choose color based on item value")
         * @param {*} element_data The value to be used with the filter. May be a primitive value, or a data object for a single item
         * @param {Number} data_index The array index for the data element
         * @returns {*} The transformed value
         */
        resolveScalableParameter (option_layout, element_data, data_index) {
            let ret = null;
            if (Array.isArray(option_layout)) {
                let idx = 0;
                while (ret === null && idx < option_layout.length) {
                    ret = this.resolveScalableParameter(option_layout[idx], element_data, data_index);
                    idx++;
                }
            } else {
                switch (typeof option_layout) {
                case 'number':
                case 'string':
                    ret = option_layout;
                    break;
                case 'object':
                    if (option_layout.scale_function) {
                        const func = SCALABLE.get(option_layout.scale_function);
                        if (option_layout.field) {
                            const f = new Field(option_layout.field);
                            let extra;
                            try {
                                extra = this.getElementAnnotation(element_data);
                            } catch (e) {
                                extra = null;
                            }
                            ret = func(option_layout.parameters || {}, f.resolve(element_data, extra), data_index);
                        } else {
                            ret = func(option_layout.parameters || {}, element_data, data_index);
                        }
                    }
                    break;
                }
            }
            return ret;
        }

        /**
         * Generate dimension extent function based on layout parameters
         * @ignore
         * @protected
         * @param {('x'|'y')} dimension
         */
        getAxisExtent (dimension) {
            if (!['x', 'y'].includes(dimension)) {
                throw new Error('Invalid dimension identifier');
            }

            const axis_name = `${dimension}_axis`;
            const axis_layout = this.layout[axis_name];

            // If a floor AND a ceiling are explicitly defined then just return that extent and be done
            if (!isNaN(axis_layout.floor) && !isNaN(axis_layout.ceiling)) {
                return [+axis_layout.floor, +axis_layout.ceiling];
            }

            // If a field is defined for the axis and the data layer has data then generate the extent from the data set
            let data_extent = [];
            if (axis_layout.field && this.data) {
                if (!this.data.length) {
                    // If data has been fetched (but no points in region), enforce the min_extent (with no buffers,
                    //  because we don't need padding around an empty screen)
                    data_extent = axis_layout.min_extent || [];
                    return data_extent;
                } else {
                    data_extent = this._getDataExtent(this.data, axis_layout);

                    // Apply upper/lower buffers, if applicable
                    const original_extent_span = data_extent[1] - data_extent[0];
                    if (!isNaN(axis_layout.lower_buffer)) {
                        data_extent[0] -= original_extent_span * axis_layout.lower_buffer;
                    }
                    if (!isNaN(axis_layout.upper_buffer)) {
                        data_extent[1] += original_extent_span * axis_layout.upper_buffer;
                    }

                    if (typeof axis_layout.min_extent == 'object') {
                        // The data should span at least the range specified by min_extent, an array with [low, high]
                        const range_min = axis_layout.min_extent[0];
                        const range_max = axis_layout.min_extent[1];
                        if (!isNaN(range_min) && !isNaN(range_max)) {
                            data_extent[0] = Math.min(data_extent[0], range_min);
                        }
                        if (!isNaN(range_max)) {
                            data_extent[1] = Math.max(data_extent[1], range_max);
                        }
                    }
                    // If specified, floor and ceiling will override the actual data range
                    return [
                        isNaN(axis_layout.floor) ? data_extent[0] : axis_layout.floor,
                        isNaN(axis_layout.ceiling) ? data_extent[1] : axis_layout.ceiling,
                    ];
                }
            }

            // If this is for the x axis and no extent could be generated yet but state has a defined start and end
            // then default to using the state-defined region as the extent
            if (dimension === 'x' && !isNaN(this.state.start) && !isNaN(this.state.end)) {
                return [this.state.start, this.state.end];
            }

            // No conditions met for generating a valid extent, return an empty array
            return [];
        }

        /**
         * Allow this data layer to tell the panel what axis ticks it thinks it will require. The panel may choose whether
         *   to use some, all, or none of these when rendering, either alone or in conjunction with other data layers.
         *
         *   This method is a stub and should be overridden in data layers that need to specify custom behavior.
         *
         * @protected
         * @param {('x'|'y1'|'y2')} dimension
         * @param {Object} [config] Additional parameters for the panel to specify how it wants ticks to be drawn. The names
         *   and meanings of these parameters may vary between different data layers.
         * @returns {Object[]}
         *   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
         */
        getTicks (dimension, config) {
            if (!['x', 'y1', 'y2'].includes(dimension)) {
                throw new Error(`Invalid dimension identifier ${dimension}`);
            }
            return [];
        }

        /**
         * Determine the coordinates for where to point the tooltip at. Typically, this is the center of a datum element (eg,
         *  the middle of a scatter plot point). Also provide an offset if the tooltip should not be at that center (most
         *  elements are not single points, eg a scatter plot point has a radius and a gene is a rectangle).
         *  The default implementation is quite naive: it places the tooltip at the origin for that layer. Individual layers
         *    should override this method to position relative to the chosen data element or mouse event.
         * @protected
         * @param {Object} tooltip A tooltip object (including attribute tooltip.data)
         * @returns {Object} as {x_min, x_max, y_min, y_max} in px, representing bounding box of a rectangle around the data pt
         *  Note that these pixels are in the SVG coordinate system
         */
        _getTooltipPosition(tooltip) {
            const panel = this.parent;

            const y_scale = panel[`y${this.layout.y_axis.axis}_scale`];
            const y_extent = panel[`y${this.layout.y_axis.axis}_extent`];

            const x = panel.x_scale(panel.x_extent[0]);
            const y = y_scale(y_extent[0]);

            return { x_min: x, x_max: x, y_min: y, y_max: y };
        }

        /**
         * Draw a tooltip on the data layer pointed at the specified coordinates, in the specified orientation.
         *  Tooltip will be drawn on the edge of the major axis, and centered along the minor axis- see diagram.
         *   v
         * > o <
         *   ^
         *
         * @protected
         * @param tooltip {Object} The object representing all data for the tooltip to be drawn
         * @param {'vertical'|'horizontal'|'top'|'bottom'|'left'|'right'} position Where to draw the tooltip relative to
         *  the data
         * @param {Number} x_min The min x-coordinate for the bounding box of the data element
         * @param {Number} x_max The max x-coordinate for the bounding box of the data element
         * @param {Number} y_min The min y-coordinate for the bounding box of the data element
         * @param {Number} y_max The max y-coordinate for the bounding box of the data element
         */
        _drawTooltip(tooltip, position, x_min, x_max, y_min, y_max) {
            const panel_layout = this.parent.layout;
            const plot_layout = this.parent_plot.layout;
            const layer_layout = this.layout;

            // Tooltip position params: as defined in the default stylesheet, used in calculations
            const arrow_size = 7;
            const stroke_width = 1;
            const arrow_total = arrow_size + stroke_width;  // Tooltip pos should account for how much space the arrow takes up

            const tooltip_padding = 6;  // bbox size must account for any internal padding applied between data and border

            const page_origin = this._getPageOrigin();
            const tooltip_box = tooltip.selector.node().getBoundingClientRect();
            const data_layer_height = panel_layout.height - (panel_layout.margin.top + panel_layout.margin.bottom);
            const data_layer_width = plot_layout.width - (panel_layout.margin.left + panel_layout.margin.right);

            // Clip the edges of the datum to the available plot area
            x_min = Math.max(x_min, 0);
            x_max = Math.min(x_max, data_layer_width);
            y_min = Math.max(y_min, 0);
            y_max = Math.min(y_max, data_layer_height);

            const x_center = (x_min + x_max) / 2;
            const y_center = (y_min + y_max) / 2;
            // Default offsets are the far edge of the datum bounding box
            let x_offset = x_max - x_center;
            let y_offset = y_max - y_center;
            let placement = layer_layout.tooltip_positioning;

            // Coordinate system note: the tooltip is positioned relative to the plot/page; the arrow is positioned relative to
            //  the tooltip boundaries
            let tooltip_top, tooltip_left, arrow_type, arrow_top, arrow_left;

            // The user can specify a generic orientation, and LocusZoom will autoselect whether to place the tooltip above or below
            if (placement === 'vertical') {
                // Auto-select whether to position above the item, or below
                x_offset = 0;
                if (tooltip_box.height + arrow_total > data_layer_height - (y_center + y_offset)) {
                    placement = 'top';
                } else {
                    placement = 'bottom';
                }
            } else if (placement === 'horizontal') {
                // Auto select whether to position to the left of the item, or to the right
                y_offset = 0;
                if (x_center <= plot_layout.width / 2) {
                    placement = 'left';
                } else {
                    placement = 'right';
                }
            }

            if (placement === 'top' || placement === 'bottom') {
                // Position horizontally centered above the point
                const offset_right = Math.max((tooltip_box.width / 2) - x_center, 0);
                const offset_left = Math.max((tooltip_box.width / 2) + x_center - data_layer_width, 0);
                tooltip_left = page_origin.x + x_center - (tooltip_box.width / 2) - offset_left + offset_right;
                arrow_left =  page_origin.x + x_center - tooltip_left - arrow_size;  // Arrow should be centered over the data
                // Position vertically above the point unless there's insufficient space, then go below
                if (placement === 'top') {
                    tooltip_top = page_origin.y + y_center - (y_offset + tooltip_box.height + arrow_total);
                    arrow_type = 'down';
                    arrow_top = tooltip_box.height - stroke_width;
                } else {
                    tooltip_top = page_origin.y + y_center + y_offset + arrow_total;
                    arrow_type = 'up';
                    arrow_top = 0 - arrow_total;
                }
            } else if (placement === 'left' || placement === 'right') {
                // Position tooltip horizontally on the left or the right depending on which side of the plot the point is on
                if (placement === 'left') {
                    tooltip_left = page_origin.x + x_center + x_offset + arrow_total;
                    arrow_type = 'left';
                    arrow_left = -1 * (arrow_size + stroke_width);
                } else {
                    tooltip_left = page_origin.x + x_center - tooltip_box.width - x_offset - arrow_total;
                    arrow_type = 'right';
                    arrow_left = tooltip_box.width - stroke_width;
                }
                // Position with arrow vertically centered along tooltip edge unless we're at the top or bottom of the plot
                if (y_center - (tooltip_box.height / 2) <= 0) { // Too close to the top, push it down
                    tooltip_top = page_origin.y + y_center - (1.5 * arrow_size) - tooltip_padding;
                    arrow_top = tooltip_padding;
                } else if (y_center + (tooltip_box.height / 2) >= data_layer_height) { // Too close to the bottom, pull it up
                    tooltip_top = page_origin.y + y_center + arrow_size + tooltip_padding - tooltip_box.height;
                    arrow_top = tooltip_box.height - (2 * arrow_size) - tooltip_padding;
                } else { // vertically centered
                    tooltip_top = page_origin.y + y_center - (tooltip_box.height / 2);
                    arrow_top = (tooltip_box.height / 2) - arrow_size;
                }
            } else {
                throw new Error('Unrecognized placement value');
            }

            // Position the div itself, relative to the layer origin
            tooltip.selector
                .style('left', `${tooltip_left}px`)
                .style('top', `${tooltip_top}px`);
            // Create / update position on arrow connecting tooltip to data
            if (!tooltip.arrow) {
                tooltip.arrow = tooltip.selector.append('div')
                    .style('position', 'absolute');
            }
            tooltip.arrow
                .attr('class', `lz-data_layer-tooltip-arrow_${arrow_type}`)
                .style('left', `${arrow_left}px`)
                .style('top', `${arrow_top}px`);
            return this;
        }

        /**
         * Determine whether a given data element matches all predefined filter criteria, usually as specified in a layout directive.
         *
         * Typically this is used with array.filter (the first argument is curried, `this.filter.bind(this, options)`
         * @private
         * @param {Object[]} filter_rules A list of rule entries: {field, value, operator} describing each filter.
         *  Operator must be from a list of built-in operators. If the field is omitted, the entire datum object will be
         *  passed to the filter, rather than a single scalar value. (this is only useful with custom `MatchFunctions` as operator)
         * @param {Object} item
         * @param {Number} index
         * @param {Array} array
         * @returns {Boolean} Whether the specified item is a match
         */
        filter(filter_rules, item, index, array) {
            let is_match = true;
            filter_rules.forEach((filter) => { // Try each filter on this item, in sequence
                const {field, operator, value: target} = filter;
                const test_func = MATCHERS.get(operator);

                // Return the field value or annotation. If no `field` is specified, the filter function will operate on
                //  the entire data object. This behavior is only really useful with custom functions, because the
                //  builtin ones expect to receive a scalar value
                const extra = this.getElementAnnotation(item);
                const field_value = field ? (new Field(field)).resolve(item, extra) : item;
                if (!test_func(field_value, target)) {
                    is_match = false;
                }
            });
            return is_match;
        }

        /**
         * Get "annotation" metadata associated with a particular point.
         *
         * @protected
         * @param {String|Object} element The data object or ID string for the element
         * @param {String} [key] The name of the annotation to track. If omitted, returns all annotations for this element as an object.
         * @return {*}
         */
        getElementAnnotation (element, key) {
            const id = this.getElementId(element);
            const extra = this._layer_state.extra_fields[id];
            return key ? (extra && extra[key]) : extra;
        }

        /****** Private methods: rarely overridden or modified by external usages */

        /**
         * Apply filtering options to determine the set of data to render
         *
         * This must be applied on rendering, not fetch, so that the axis limits reflect the true range of the dataset
         *   Otherwise, two stacked panels (same dataset filtered in different ways) might not line up on the x-axis when
         *   filters are applied.
         * @param data
         * @return {*}
         * @private
         */
        _applyFilters(data) {
            data = data || this.data;

            if (this._filter_func) {
                data = data.filter(this._filter_func);
            } else if (this.layout.filters) {
                data = data.filter(this.filter.bind(this, this.layout.filters));
            }
            return data;
        }

        /**
         * Define default state that should get tracked during the lifetime of this layer.
         *
         * In some special custom usages, it may be useful to completely reset a panel (eg "click for
         *   genome region" links), plotting new data that invalidates any previously tracked state.  This hook makes it
         *   possible to reset without destroying the panel entirely. It is used by `Plot.clearPanelData`.
         * @private
         */
        _setDefaultState() {
            // Each datalayer tracks two kinds of status: flags for internal state (highlighted, selected, tooltip),
            //  and "extra fields" (annotations like "show a tooltip" that are not determined by the server, but need to
            //  persist across re-render)
            const _layer_state = { status_flags: {}, extra_fields: {} };
            const status_flags = _layer_state.status_flags;
            STATUSES.adjectives.forEach((status) => {
                status_flags[status] = status_flags[status] || new Set();
            });
            // Also initialize "internal-only" state fields (things that are tracked, but not set directly by external events)
            status_flags['has_tooltip'] = status_flags['has_tooltip'] || new Set();

            if (this.parent) {
                // If layer has a parent, store a reference in the overarching plot.state object
                this._state_id = `${this.parent.id}.${this.id}`;
                this.state = this.parent.state;
                this.state[this._state_id] = _layer_state;
            }
            this._layer_state = _layer_state;
        }

        /**
         * Get the fully qualified identifier for the data layer, prefixed by any parent or container elements
         *
         * @private
         * @returns {string} A dot-delimited string of the format <plot>.<panel>.<data_layer>
         */
        getBaseId () {
            if (this._base_id) {
                return this._base_id;
            }

            if (this.parent) {
                return `${this.parent_plot.id}.${this.parent.id}.${this.id}`;
            } else {
                return (this.id || '').toString();
            }
        }

        /**
         * Determine the pixel height of data-bound objects represented inside this data layer. (excluding elements such as axes)
         *
         * May be used by operations that resize the data layer to fit available data
         *
         * @private
         * @returns {number}
         */
        getAbsoluteDataHeight() {
            const dataBCR = this.svg.group.node().getBoundingClientRect();
            return dataBCR.height;
        }

        /**
         * Initialize a data layer
         * @private
         * @returns {BaseDataLayer}
         */
        initialize() {
            this._base_id = this.getBaseId();

            // Append a container group element to house the main data layer group element and the clip path
            const base_id = this.getBaseId();
            this.svg.container = this.parent.svg.group.append('g')
                .attr('class', 'lz-data_layer-container')
                .attr('id', `${base_id}.data_layer_container`);

            // Append clip path to the container element
            this.svg.clipRect = this.svg.container.append('clipPath')
                .attr('id', `${base_id}.clip`)
                .append('rect');

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

            return this;

        }

        /**
         * Generate a tool tip for a given element
         * @private
         * @param {String|Object} data Data for the element associated with the tooltip
         */
        createTooltip (data) {
            if (typeof this.layout.tooltip != 'object') {
                throw new Error(`DataLayer [${this.id}] layout does not define a tooltip`);
            }
            const id = this.getElementId(data);
            if (this._tooltips[id]) {
                this.positionTooltip(id);
                return;
            }
            this._tooltips[id] = {
                data: data,
                arrow: null,
                selector: d3.select(this.parent_plot.svg.node().parentNode).append('div')
                    .attr('class', 'lz-data_layer-tooltip')
                    .attr('id', `${id}-tooltip`),
            };
            this._layer_state.status_flags['has_tooltip'].add(id);
            this.updateTooltip(data);
            return this;
        }

        /**
         * Update a tool tip (generate its inner HTML)
         *
         * @private
         * @param {String|Object} d The element associated with the tooltip
         * @param {String} [id] An identifier to the tooltip
         */
        updateTooltip(d, id) {
            if (typeof id == 'undefined') {
                id = this.getElementId(d);
            }
            // Empty the tooltip of all HTML (including its arrow!)
            this._tooltips[id].selector.html('');
            this._tooltips[id].arrow = null;
            // Set the new HTML
            if (this.layout.tooltip.html) {
                this._tooltips[id].selector.html(parseFields(this.layout.tooltip.html, d, this.getElementAnnotation(d)));
            }
            // If the layout allows tool tips on this data layer to be closable then add the close button
            // and add padding to the tooltip to accommodate it
            if (this.layout.tooltip.closable) {
                this._tooltips[id].selector.insert('button', ':first-child')
                    .attr('class', 'lz-tooltip-close-button')
                    .attr('title', 'Close')
                    .text('×')
                    .on('click', () => {
                        this.destroyTooltip(id);
                    });
            }
            // Apply data directly to the tool tip for easier retrieval by custom UI elements inside the tool tip
            this._tooltips[id].selector.data([d]);
            // Reposition and draw a new arrow
            this.positionTooltip(id);
            return this;
        }

        /**
         * Destroy tool tip - remove the tool tip element from the DOM and delete the tool tip's record on the data layer
         *
         * @private
         * @param {String|Object} element_or_id The element (or id) associated with the tooltip
         * @param {boolean} [temporary=false] Whether this is temporary (not to be tracked in state). Differentiates
         *  "recreate tooltips on re-render" (which is temporary) from "user has closed this tooltip" (permanent)
         * @returns {BaseDataLayer}
         */
        destroyTooltip(element_or_id, temporary) {
            let id;
            if (typeof element_or_id == 'string') {
                id = element_or_id;
            } else {
                id = this.getElementId(element_or_id);
            }
            if (this._tooltips[id]) {
                if (typeof this._tooltips[id].selector == 'object') {
                    this._tooltips[id].selector.remove();
                }
                delete this._tooltips[id];
            }
            // When a tooltip is removed, also remove the reference from the state
            if (!temporary) {
                const tooltip_state = this._layer_state.status_flags['has_tooltip'];
                tooltip_state.delete(id);
            }
            return this;
        }

        /**
         * Loop through and destroy all tool tips on this data layer
         *
         * @private
         * @returns {BaseDataLayer}
         */
        destroyAllTooltips(temporary = true) {
            for (let id in this._tooltips) {
                this.destroyTooltip(id, temporary);
            }
            return this;
        }

        /**
         * Position and then redraw tool tip - naïve function to place a tool tip in the data layer. By default, positions wrt
         *   the top-left corner of the data layer.
         *
         * Each layer type may have more specific logic. Consider overriding the provided hooks `_getTooltipPosition` or
         *  `_drawTooltip` as appropriate
         *
         * @private
         * @param {String} id The identifier of the tooltip to position
         * @returns {BaseDataLayer}
         */
        positionTooltip(id) {
            if (typeof id != 'string') {
                throw new Error('Unable to position tooltip: id is not a string');
            }
            if (!this._tooltips[id]) {
                throw new Error('Unable to position tooltip: id does not point to a valid tooltip');
            }
            const tooltip = this._tooltips[id];
            const coords = this._getTooltipPosition(tooltip);

            if (!coords) {
                // Special cutout: normally, tooltips are positioned based on the datum element. Some, like lines/curves,
                //  work better if based on a mouse event. Since not every redraw contains a mouse event, we can just skip
                //  calculating position when no position information is available.
                return null;
            }
            this._drawTooltip(tooltip, this.layout.tooltip_positioning, coords.x_min, coords.x_max, coords.y_min, coords.y_max);
        }

        /**
         * Loop through and position all tool tips on this data layer
         *
         * @private
         * @returns {BaseDataLayer}
         */
        positionAllTooltips() {
            for (let id in this._tooltips) {
                this.positionTooltip(id);
            }
            return this;
        }

        /**
         * Show or hide a tool tip by ID depending on directives in the layout and state values relative to the ID
         *
         * @private
         * @param {String|Object} element The element associated with the tooltip
         * @param {boolean} first_time Because panels can re-render, the rules for showing a tooltip
         *  depend on whether this is the first time a status change affecting display has been applied.
         * @returns {BaseDataLayer}
         */
        showOrHideTooltip(element, first_time) {
            const tooltip_layout = this.layout.tooltip;
            if (typeof tooltip_layout != 'object') {
                return this;
            }
            const id = this.getElementId(element);

            /**
             * Apply rules and decide whether to show or hide the tooltip
             * @param {Object} statuses All statuses that apply to an element
             * @param {String[]|object} directive A layout directive object
             * @param operator
             * @returns {null|bool}
             */
            const resolveStatus = (statuses, directive, operator) => {
                let status = null;
                if (typeof statuses != 'object' || statuses === null) {
                    return null;
                }
                if (Array.isArray(directive)) {
                    // This happens when the function is called on the inner part of the directive
                    operator = operator || 'and';
                    if (directive.length === 1) {
                        status = statuses[directive[0]];
                    } else {
                        status = directive.reduce((previousValue, currentValue) => {
                            if (operator === 'and') {
                                return statuses[previousValue] && statuses[currentValue];
                            } else if (operator === 'or') {
                                return statuses[previousValue] || statuses[currentValue];
                            }
                            return null;
                        });
                    }
                } else if (typeof directive == 'object') {
                    let sub_status;
                    for (let sub_operator in directive) {
                        sub_status = resolveStatus(statuses, directive[sub_operator], sub_operator);
                        if (status === null) {
                            status = sub_status;
                        } else if (operator === 'and') {
                            status = status && sub_status;
                        } else if (operator === 'or') {
                            status = status || sub_status;
                        }
                    }
                } else {
                    return false;
                }
                return status;
            };

            let show_directive = {};
            if (typeof tooltip_layout.show == 'string') {
                show_directive = { and: [ tooltip_layout.show ] };
            } else if (typeof tooltip_layout.show == 'object') {
                show_directive = tooltip_layout.show;
            }

            let hide_directive = {};
            if (typeof tooltip_layout.hide == 'string') {
                hide_directive = { and: [ tooltip_layout.hide ] };
            } else if (typeof tooltip_layout.hide == 'object') {
                hide_directive = tooltip_layout.hide;
            }

            // Find all the statuses that apply to just this single element
            const _layer_state = this._layer_state;
            var status_flags = {};  // {status_name: bool}
            STATUSES.adjectives.forEach((status) => {
                const antistatus = `un${status}`;
                status_flags[status] = (_layer_state.status_flags[status].has(id));
                status_flags[antistatus] = !status_flags[status];
            });

            // Decide whether to show/hide the tooltip based solely on the underlying element
            const show_resolved = resolveStatus(status_flags, show_directive);
            const hide_resolved = resolveStatus(status_flags, hide_directive);

            // Most of the tooltip display logic depends on behavior layouts: was point (un)selected, (un)highlighted, etc.
            // But sometimes, a point is selected, and the user then closes the tooltip. If the panel is re-rendered for
            //  some outside reason (like state change), we must track this in the create/destroy events as tooltip state.
            const has_tooltip = (_layer_state.status_flags['has_tooltip'].has(id));
            const tooltip_was_closed = first_time ? false : !has_tooltip;
            if (show_resolved && !tooltip_was_closed && !hide_resolved) {
                this.createTooltip(element);
            } else {
                this.destroyTooltip(element);
            }

            return this;
        }

        /**
         * Toggle a status (e.g. highlighted, selected, identified) on an element
         *
         * @private
         * @fires event:layout_changed
         * @fires event:element_selection
         * @fires event:match_requested
         * @param {String} status The name of a recognized status to be added/removed on an appropriate element
         * @param {String|Object} element The data bound to the element of interest
         * @param {Boolean} active True to add the status (and associated CSS styles); false to remove it
         * @param {Boolean} exclusive Whether to only allow a state for a single element at a time
         * @returns {BaseDataLayer}
         */
        setElementStatus(status, element, active, exclusive) {
            if (status === 'has_tooltip') {
                // This is a special adjective that exists solely to track tooltip state. It has no CSS and never gets set
                //  directly. It is invisible to the official enums.
                return this;
            }
            if (typeof active == 'undefined') {
                active = true;
            }

            // Get an ID for the element or return having changed nothing
            let element_id;
            try {
                element_id = this.getElementId(element);
            } catch (get_element_id_error) {
                return this;
            }

            // Enforce exclusivity (force all elements to have the opposite of toggle first)
            if (exclusive) {
                this.setAllElementStatus(status, !active);
            }

            // Set/unset the proper status class on the appropriate DOM element(s), *and* potentially an additional element
            d3.select(`#${element_id}`).classed(`lz-data_layer-${this.layout.type}-${status}`, active);
            const element_status_node_id = this.getElementStatusNodeId(element);
            if (element_status_node_id !== null) {
                d3.select(`#${element_status_node_id}`).classed(`lz-data_layer-${this.layout.type}-statusnode-${status}`, active);
            }

            // Track element ID in the proper status state array
            const added_status = !this._layer_state.status_flags[status].has(element_id);  // On a re-render, existing statuses will be reapplied.
            if (active && added_status) {
                this._layer_state.status_flags[status].add(element_id);
            }
            if (!active && !added_status) {
                this._layer_state.status_flags[status].delete(element_id);
            }

            // Trigger tool tip show/hide logic
            this.showOrHideTooltip(element, added_status);

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

            const is_selected = (status === 'selected');
            if (is_selected && (added_status || !active)) {
                // Notify parents that an element has changed selection status (either active, or inactive)
                this.parent.emit('element_selection', { element: element, active: active }, true);
            }

            const value_to_broadcast = (this.layout.match && this.layout.match.send);
            if (is_selected && (typeof value_to_broadcast !== 'undefined') && (added_status || !active)) {
                this.parent.emit(
                    // The broadcast value can use transforms to "clean up value before sending broadcasting"
                    'match_requested',
                    { value: new Field(value_to_broadcast).resolve(element), active: active },
                    true,
                );
            }
            return this;
        }

        /**
         * Toggle a status on all elements in the data layer
         *
         * @private
         * @param {String} status
         * @param {Boolean} toggle
         * @returns {BaseDataLayer}
         */
        setAllElementStatus(status, toggle) {

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

            // Apply statuses
            if (toggle) {
                this.data.forEach((element) => this.setElementStatus(status, element, true));
            } else {
                const status_ids = new Set(this._layer_state.status_flags[status]); // copy so that we don't mutate while iterating
                status_ids.forEach((id) => {
                    const element = this.getElementById(id);
                    if (typeof element == 'object' && element !== null) {
                        this.setElementStatus(status, element, false);
                    }
                });
                this._layer_state.status_flags[status] = new Set();
            }

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

            return this;
        }

        /**
         * Apply all layout-defined behaviors (DOM event handlers) to a selection of elements
         *
         * @private
         * @param {d3.selection} selection
         */
        applyBehaviors(selection) {
            if (typeof this.layout.behaviors != 'object') {
                return;
            }
            Object.keys(this.layout.behaviors).forEach((directive) => {
                const event_match = /(click|mouseover|mouseout)/.exec(directive);
                if (!event_match) {
                    return;
                }
                selection.on(`${event_match[0]}.${directive}`, this.executeBehaviors(directive, this.layout.behaviors[directive]));
            });
        }

        /**
         * Generate a function that executes an arbitrary list of behaviors on an element during an event
         *
         * @private
         * @param {String} directive The name of the event, as described in layout.behaviors for this datalayer
         * @param {Object[]} behaviors An object describing the behavior to attach to this single element
         * @param {string} behaviors.action The name of the action that would trigger this behavior (eg click, mouseover, etc)
         * @param {string} behaviors.status What status to apply to the element when this behavior is triggered (highlighted,
         *  selected, etc)
         * @param {boolean} [behaviors.exclusive] Whether triggering the event for this element should unset the relevant status
         *   for all other elements. Useful for, eg, click events that exclusively highlight one thing.
         * @returns {function(this:BaseDataLayer)} Return a function that handles the event in context with the behavior
         *   and the element- can be attached as an event listener
         */
        executeBehaviors(directive, behaviors) {

            // Determine the required state of control and shift keys during the event
            const requiredKeyStates = {
                'ctrl': (directive.includes('ctrl')),
                'shift': (directive.includes('shift')),
            };
            const self = this;
            return function(element) {
                // This method may be used on two kinds of events: directly attached, or bubbled.
                // D3 doesn't natively support bubbling very well; if no data is bound on the currentTarget, check to see
                //  if there is data available at wherever the event was initiated from
                element = element || d3.select(d3.event.target).datum();

                // Do nothing if the required control and shift key presses (or lack thereof) doesn't match the event
                if (requiredKeyStates.ctrl !== !!d3.event.ctrlKey || requiredKeyStates.shift !== !!d3.event.shiftKey) {
                    return;
                }

                // Loop through behaviors making each one go in succession
                behaviors.forEach((behavior) => {

                    // Route first by the action, if defined
                    if (typeof behavior != 'object' || behavior === null) {
                        return;
                    }

                    switch (behavior.action) {

                    // Set a status (set to true regardless of current status, optionally with exclusivity)
                    case 'set':
                        self.setElementStatus(behavior.status, element, true, behavior.exclusive);
                        break;

                    // Unset a status (set to false regardless of current status, optionally with exclusivity)
                    case 'unset':
                        self.setElementStatus(behavior.status, element, false, behavior.exclusive);
                        break;

                    // Toggle a status
                    case 'toggle':
                        var current_status_boolean = (self._layer_state.status_flags[behavior.status].has(self.getElementId(element)));
                        var exclusive = behavior.exclusive && !current_status_boolean;

                        self.setElementStatus(behavior.status, element, !current_status_boolean, exclusive);
                        break;

                    // Link to a dynamic URL
                    case 'link':
                        if (typeof behavior.href == 'string') {
                            const url = parseFields(behavior.href, element, self.getElementAnnotation(element));
                            if (typeof behavior.target == 'string') {
                                window.open(url, behavior.target);
                            } else {
                                window.location.href = url;
                            }
                        }
                        break;

                    // Action not defined, just return
                    default:
                        break;
                    }
                });
            };
        }

        /**
         * 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 panel_origin = this.parent._getPageOrigin();
            return {
                x: panel_origin.x + this.parent.layout.margin.left,
                y: panel_origin.y + this.parent.layout.margin.top,
            };
        }

        /**
         * Apply all tracked element statuses. This is primarily intended for re-rendering the plot, in order to preserve
         *  behaviors when items are updated.
         *  @private
         */
        applyAllElementStatus () {
            const status_flags = this._layer_state.status_flags;
            const self = this;
            for (let property in status_flags) {
                if (!Object.prototype.hasOwnProperty.call(status_flags, property)) {
                    continue;
                }
                status_flags[property].forEach((element_id) => {
                    try {
                        this.setElementStatus(property, this.getElementById(element_id), true);
                    } catch (e) {
                        console.warn(`Unable to apply state: ${self._state_id}, ${property}`);
                        console.error(e);
                    }
                });

            }
        }

        /**
         * Position the datalayer and all tooltips
         * @private
         * @returns {BaseDataLayer}
         */
        draw() {
            this.svg.container
                .attr('transform', `translate(${this.parent.layout.cliparea.origin.x}, ${this.parent.layout.cliparea.origin.y})`);
            this.svg.clipRect
                .attr('width', this.parent.layout.cliparea.width)
                .attr('height', this.parent.layout.cliparea.height);
            this.positionAllTooltips();
            return this;
        }

        /**
         * Re-Map a data layer to reflect changes in the state of a plot (such as viewing region/ chromosome range)
         *
         * Whereas .render draws whatever data is available, this method resets the view and fetches new data if necessary.
         *
         * @private
         * @return {Promise}
         */
        reMap() {
            this.destroyAllTooltips(); // hack - only non-visible tooltips should be destroyed
            // and then recreated if returning to visibility

            // Fetch new data. Datalayers are only given access to the final consolidated data from the chain (not headers or raw payloads)
            return this.parent_plot.lzd.getData(this.state, this._entities, this._dependencies)
                .then((new_data) => {
                    this.data = new_data;
                    this.applyDataMethods();
                    this._initialized = true;
                    // Allow listeners (like subscribeToData) to see the information associated with a layer
                    this.parent.emit(
                        'data_from_layer',
                        { layer: this.getBaseId(), content: deepCopy(new_data) }, // TODO: revamp event signature post layer-eventing-mixin
                        true,
                    );
                });
        }
    }

    STATUSES.verbs.forEach((verb, idx) => {
        const adjective = STATUSES.adjectives[idx];
        const antiverb = `un${verb}`;
        // Set/unset a single element's status

        /**
         * @private
         * @function highlightElement
         */
        /**
         * @private
         * @function selectElement
         */
        /**
         *  @private
         *  @function fadeElement
         */
        /**
         *  @private
         *  @function hideElement
         */
        BaseDataLayer.prototype[`${verb}Element`] = function(element, exclusive = false) {
            exclusive = !!exclusive;
            this.setElementStatus(adjective, element, true, exclusive);
            return this;
        };

        /**
         * @private
         * @function unhighlightElement
         */
        /**
         *  @private
         *  @function unselectElement
         */
        /**
         *  @private
         *  @function unfadeElement
         */
        /**
         *  @private
         *  @function unhideElement
         */
        BaseDataLayer.prototype[`${antiverb}Element`] = function(element, exclusive) {
            if (typeof exclusive == 'undefined') {
                exclusive = false;
            } else {
                exclusive = !!exclusive;
            }
            this.setElementStatus(adjective, element, false, exclusive);
            return this;
        };

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

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

    export {BaseDataLayer as default};