# Source: components/data_layer/scatter.js

    import * as d3 from 'd3';
    import BaseDataLayer from './base';
    import {applyStyles} from '../../helpers/common';
    import {parseFields} from '../../helpers/display';
    import {merge, nameToSymbol} from '../../helpers/layouts';
    import {coalesce_scatter_points} from '../../helpers/render';

    /**
     * @memberof module:LocusZoom_DataLayers~scatter
     */
    const default_layout = {
        point_size: 40,
        point_shape: 'circle',
        tooltip_positioning: 'horizontal',
        color: '#888888',
        coalesce: {
            active: false,
            max_points: 800, // Many plots are 800-2400 px wide, so, more than 1 datum per pixel of average region width
            // Define the "region of interest", like "bottom half of plot"; any points outside this region are taken as is
            //   Values are expressed in terms of data value and will be converted to pixels internally.
            x_min: '-Infinity',  // JSON doesn't handle some valid JS numbers. Kids, don't get a career in computers.
            x_max: 'Infinity',
            y_min: 0,
            y_max: 3.0,
            x_gap: 7,
            y_gap: 7,
        },
        fill_opacity: 1,
        y_axis: {
            axis: 1,
        },
        id_field: 'id',
    };

    /**
     * Options that control point-coalescing in scatter plots
     * @typedef {object} module:LocusZoom_DataLayers~scatter~coalesce_options
     * @property {boolean} [active=false] Whether to use this feature. Typically used for GWAS plots, but
     *   not other scatter plots such as PheWAS.
     * @property {number} [max_points=800] Only attempt to reduce DOM size if there are at least this many
     *  points. Many plots are 800-2400 px wide, so, more than 1 datum per pixel of average region width. For more
     *  sparse datasets, all points will be faithfully rendered even if coalesce.active=true.
     * @property {number} [x_min='-Infinity'] Min x coordinate of the region where points will be coalesced
     * @property {number} [x_max='Infinity'] Max x coordinate of the region where points will be coalesced
     * @property {number} [y_min=0] Min y coordinate of the region where points will be coalesced.
     * @property {number} [y_max=3.0] Max y coordinate of the region where points will be coalesced
     * @property {number} [x_gap=7] Max number of pixels between the center of two points that can be
     *   coalesced. For circles, area 40 = radius ~3.5; aim for ~1 diameter distance.
     * @property {number} [y_gap=7]
     */

    /**
     * Scatter Data Layer
     * Implements a standard scatter plot
     * @alias module:LocusZoom_DataLayers~scatter
     */
    class Scatter extends BaseDataLayer {
        /**
         * @param {number|module:LocusZoom_DataLayers~ScalableParameter[]} [layout.point_size=40] The size (area) of the point for each datum
         * @param {string|module:LocusZoom_DataLayers~ScalableParameter[]} [layout.point_shape='circle'] Shape of the point for each datum. Supported values map to the d3 SVG Symbol Types (i.e.: "circle", "cross", "diamond", "square", "triangle", "star", and "wye"), plus "triangledown".
         * @param {string|module:LocusZoom_DataLayers~ScalableParameter[]} [layout.color='#888888'] The color of the point for each datum
         * @param {module:LocusZoom_DataLayers~scatter~coalesce_options} [layout.coalesce] Options to control whether and how to combine adjacent insignificant ("within region of interest") points
         *   to improve rendering performance. These options are primarily aimed at GWAS region plots. Within a specified
         *   rectangle area (eg "insignificant point cutoff"), we choose only points far enough part to be seen.
         *   The defaults are specifically tuned for GWAS plots with -log(p) on the y-axis.
         * @param {number|module:LocusZoom_DataLayers~ScalableParameter[]} [layout.fill_opacity=1] Opacity (0..1) for each datum point
         * @param {string} [layout.label.text] Similar to tooltips: a template string that can reference datum fields for label text.
         * @param {number} [layout.label.spacing] Distance (in px) between the label and the center of the datum.
         * @param {object} [layout.label.lines.style] CSS style options for how the line is rendered
         * @param {number} [layout.label.filters] Filters that describe which points to label. For performance reasons,
         *   we recommend labeling only a small subset of most interesting points.
         * @param {object} [layout.label.style] CSS style options for label text
         */
        constructor(layout) {
            layout = merge(layout, default_layout);

            // Extra default for layout spacing
            // Not in default layout since that would make the label attribute always present
            if (layout.label && isNaN(layout.label.spacing)) {
                layout.label.spacing = 4;
            }
            super(...arguments);
        }

        // Implement tooltip position to be layer-specific
        _getTooltipPosition(tooltip) {
            const x_center = this.parent.x_scale(tooltip.data[this.layout.x_axis.field]);
            const y_scale = `y${this.layout.y_axis.axis}_scale`;
            const y_center = this.parent[y_scale](tooltip.data[this.layout.y_axis.field]);
            const point_size = this.resolveScalableParameter(this.layout.point_size, tooltip.data);
            const offset = Math.sqrt(point_size / Math.PI);

            return {
                x_min: x_center - offset, x_max: x_center + offset,
                y_min: y_center - offset, y_max: y_center + offset,
            };
        }

        // Function to flip labels from being anchored at the start of the text to the end
        // Both to keep labels from running outside the data layer and  also as a first
        // pass on recursive separation
        flip_labels() {
            const data_layer = this;
            // Base positions on the default point size (which is what resolve scalable param returns if no data provided)
            const point_size = data_layer.resolveScalableParameter(data_layer.layout.point_size, {});
            const spacing = data_layer.layout.label.spacing;
            const handle_lines = Boolean(data_layer.layout.label.lines);
            const min_x = 2 * spacing;
            const max_x = this.parent_plot.layout.width - this.parent.layout.margin.left - this.parent.layout.margin.right - (2 * spacing);

            const flip = (dn, dnl) => {
                const dnx = +dn.attr('x');
                const text_swing = (2 * spacing) + (2 * Math.sqrt(point_size));
                let dnlx2;
                let line_swing;
                if (handle_lines) {
                    dnlx2 = +dnl.attr('x2');
                    line_swing = spacing + (2 * Math.sqrt(point_size));
                }
                if (dn.style('text-anchor') === 'start') {
                    dn.style('text-anchor', 'end');
                    dn.attr('x', dnx - text_swing);
                    if (handle_lines) {
                        dnl.attr('x2', dnlx2 - line_swing);
                    }
                } else {
                    dn.style('text-anchor', 'start');
                    dn.attr('x', dnx + text_swing);
                    if (handle_lines) {
                        dnl.attr('x2', dnlx2 + line_swing);
                    }
                }
            };
            // Flip any going over the right edge from the right side to the left side
            // (all labels start on the right side)
            data_layer._label_texts.each(function (d, i) {
                const a = this;
                const da = d3.select(a);
                const dax = +da.attr('x');
                const abound = da.node().getBoundingClientRect();
                if (dax + abound.width + spacing > max_x) {
                    const dal = handle_lines ? d3.select(data_layer._label_lines.nodes()[i]) : null;
                    flip(da, dal);
                }
            });
            // Second pass to flip any others that haven't flipped yet if they collide with another label
            data_layer._label_texts.each(function (d, i) {
                const a = this;
                const da = d3.select(a);
                if (da.style('text-anchor') === 'end') {
                    return;
                }
                let dax = +da.attr('x');
                const abound = da.node().getBoundingClientRect();
                const dal = handle_lines ? d3.select(data_layer._label_lines.nodes()[i]) : null;
                data_layer._label_texts.each(function () {
                    const b = this;
                    const db = d3.select(b);
                    const bbound = db.node().getBoundingClientRect();
                    const collision = abound.left < bbound.left + bbound.width + (2 * spacing) &&
                        abound.left + abound.width + (2 * spacing) > bbound.left &&
                        abound.top < bbound.top + bbound.height + (2 * spacing) &&
                        abound.height + abound.top + (2 * spacing) > bbound.top;
                    if (collision) {
                        flip(da, dal);
                        // Double check that this flip didn't push the label past min_x. If it did, immediately flip back.
                        dax = +da.attr('x');
                        if (dax - abound.width - spacing < min_x) {
                            flip(da, dal);
                        }
                    }
                });
            });
        }

        // Recursive function to space labels apart immediately after initial render
        // Adapted from thudfactor's fiddle here: https://jsfiddle.net/thudfactor/HdwTH/
        // TODO: Make labels also aware of data elements
        separate_labels() {
            this._label_iterations++;
            const data_layer = this;
            const alpha = 0.5;
            if (!this.layout.label) {
                // Guard against layout changing in the midst of iterative rerender
                return;
            }
            const spacing = this.layout.label.spacing;
            let again = false;
            data_layer._label_texts.each(function () {
                // TODO: O(n2) algorithm; revisit performance?
                const a = this;
                const da = d3.select(a);
                const y1 = da.attr('y');
                data_layer._label_texts.each(function () {
                    const b = this;
                    // a & b are the same element and don't collide.
                    if (a === b) {
                        return;
                    }
                    const db = d3.select(b);
                    // a & b are on opposite sides of the chart and
                    // don't collide
                    if (da.attr('text-anchor') !== db.attr('text-anchor')) {
                        return;
                    }
                    // Determine if the  bounding rects for the two text elements collide
                    const abound = da.node().getBoundingClientRect();
                    const bbound = db.node().getBoundingClientRect();
                    const collision = abound.left < bbound.left + bbound.width + (2 * spacing) &&
                        abound.left + abound.width + (2 * spacing) > bbound.left &&
                        abound.top < bbound.top + bbound.height + (2 * spacing) &&
                        abound.height + abound.top + (2 * spacing) > bbound.top;
                    if (!collision) {
                        return;
                    }
                    again = true;
                    // If the labels collide, we'll push each
                    // of the two labels up and down a little bit.
                    const y2 = db.attr('y');
                    const sign = abound.top < bbound.top ? 1 : -1;
                    const adjust = sign * alpha;
                    let new_a_y = +y1 - adjust;
                    let new_b_y = +y2 + adjust;
                    // Keep new values from extending outside the data layer
                    const min_y = 2 * spacing;
                    const max_y = data_layer.parent.layout.height - data_layer.parent.layout.margin.top - data_layer.parent.layout.margin.bottom - (2 * spacing);
                    let delta;
                    if (new_a_y - (abound.height / 2) < min_y) {
                        delta = +y1 - new_a_y;
                        new_a_y = +y1;
                        new_b_y += delta;
                    } else if (new_b_y - (bbound.height / 2) < min_y) {
                        delta = +y2 - new_b_y;
                        new_b_y = +y2;
                        new_a_y += delta;
                    }
                    if (new_a_y + (abound.height / 2) > max_y) {
                        delta = new_a_y - +y1;
                        new_a_y = +y1;
                        new_b_y -= delta;
                    } else if (new_b_y + (bbound.height / 2) > max_y) {
                        delta = new_b_y - +y2;
                        new_b_y = +y2;
                        new_a_y -= delta;
                    }
                    da.attr('y', new_a_y);
                    db.attr('y', new_b_y);
                });
            });
            if (again) {
                // Adjust lines to follow the labels
                if (data_layer.layout.label.lines) {
                    const label_elements = data_layer._label_texts.nodes();
                    data_layer._label_lines.attr('y2', (d, i) => {
                        const label_line = d3.select(label_elements[i]);
                        return label_line.attr('y');
                    });
                }
                // After ~150 iterations we're probably beyond diminising returns, so stop recursing
                if (this._label_iterations < 150) {
                    setTimeout(() => {
                        this.separate_labels();
                    }, 1);
                }
            }
        }

        // Implement the main render function
        render() {
            const data_layer = this;
            const x_scale = this.parent['x_scale'];
            const y_scale = this.parent[`y${this.layout.y_axis.axis}_scale`];

            const xcs = Symbol.for('lzX');
            const ycs = Symbol.for('lzY');

            // Apply filters to only render a specified set of points
            let track_data = this._applyFilters();

            // Add coordinates before rendering, so we can coalesce
            track_data.forEach((item) => {
                let x = x_scale(item[this.layout.x_axis.field]);
                let y = y_scale(item[this.layout.y_axis.field]);
                if (isNaN(x)) {
                    x = -1000;
                }
                if (isNaN(y)) {
                    y = -1000;
                }
                item[xcs] = x;
                item[ycs] = y;
            });

            if (this.layout.coalesce.active && track_data.length > this.layout.coalesce.max_points) {
                let { x_min, x_max, y_min, y_max, x_gap, y_gap } = this.layout.coalesce;
                // Convert x and y "significant region" range from data values to pixels
                const x_min_px = isFinite(x_min) ? x_scale(+x_min) : -Infinity;
                const x_max_px = isFinite(x_max) ? x_scale(+x_max) : Infinity;
                // For y px, we flip the data min/max b/c in SVG coord system +y is down: smaller data y = larger px y
                const y_min_px = isFinite(y_max) ? y_scale(+y_max) : -Infinity;
                const y_max_px = isFinite(y_min) ? y_scale(+y_min) : Infinity;
                track_data = coalesce_scatter_points(track_data, x_min_px, x_max_px, x_gap, y_min_px, y_max_px, y_gap);
            }

            if (this.layout.label) {
                let label_data;
                const filters = data_layer.layout.label.filters || [];
                if (!filters.length) {
                    label_data = track_data;
                } else {
                    const func = this.filter.bind(this, filters);
                    label_data = track_data.filter(func);
                }

                // Render label groups
                this._label_groups = this.svg.group
                    .selectAll(`g.lz-data_layer-${this.layout.type}-label`)
                    .data(label_data, (d) => `${d[this.layout.id_field]}_label`);

                const style_class = `lz-data_layer-${this.layout.type}-label`;
                const groups_enter = this._label_groups.enter()
                    .append('g')
                    .attr('class', style_class);

                if (this._label_texts) {
                    this._label_texts.remove();
                }

                this._label_texts = this._label_groups.merge(groups_enter)
                    .append('text')
                    .text((d) => parseFields(data_layer.layout.label.text || '', d, this.getElementAnnotation(d)))
                    .attr('x', (d) => {
                        return d[xcs]
                            + Math.sqrt(data_layer.resolveScalableParameter(data_layer.layout.point_size, d))
                            + data_layer.layout.label.spacing;
                    })
                    .attr('y', (d) => d[ycs])
                    .attr('text-anchor', 'start')
                    .call(applyStyles, data_layer.layout.label.style || {});

                // Render label lines
                if (data_layer.layout.label.lines) {
                    if (this._label_lines) {
                        this._label_lines.remove();
                    }
                    this._label_lines = this._label_groups.merge(groups_enter)
                        .append('line')
                        .attr('x1', (d) => d[xcs])
                        .attr('y1', (d) => d[ycs])
                        .attr('x2', (d) => {
                            return d[xcs]
                                + Math.sqrt(data_layer.resolveScalableParameter(data_layer.layout.point_size, d))
                                + (data_layer.layout.label.spacing / 2);
                        })
                        .attr('y2', (d) => d[ycs])
                        .call(applyStyles, data_layer.layout.label.lines.style || {});
                }
                // Remove labels when they're no longer in the filtered data set
                this._label_groups.exit()
                    .remove();
            } else {
                // If the layout definition has changed (& no longer specifies labels), strip any previously rendered
                if (this._label_texts) {
                    this._label_texts.remove();
                }
                if (this._label_lines) {
                    this._label_lines.remove();
                }
                if (this._label_groups) {
                    this._label_groups.remove();
                }
            }

            // Generate main scatter data elements
            const selection = this.svg.group
                .selectAll(`path.lz-data_layer-${this.layout.type}`)
                .data(track_data, (d) => d[this.layout.id_field]);

            // Create elements, apply class, ID, and initial position
            // Generate new values (or functions for them) for position, color, size, and shape
            const transform = (d) => `translate(${d[xcs]}, ${d[ycs]})`;

            const shape = d3.symbol()
                .size((d, i) => this.resolveScalableParameter(this.layout.point_size, d, i))
                .type((d, i) => nameToSymbol(this.resolveScalableParameter(this.layout.point_shape, d, i)));

            const style_class = `lz-data_layer-${this.layout.type}`;
            selection.enter()
                .append('path')
                .attr('class', style_class)
                .attr('id', (d) => this.getElementId(d))
                .merge(selection)
                .attr('transform', transform)
                .attr('fill', (d, i) => this.resolveScalableParameter(this.layout.color, d, i))
                .attr('fill-opacity', (d, i) => this.resolveScalableParameter(this.layout.fill_opacity, d, i))
                .attr('d', shape);

            // Remove old elements as needed
            selection.exit()
                .remove();

            // Apply method to keep labels from overlapping each other
            if (this.layout.label) {
                this.flip_labels();
                this._label_iterations = 0;
                this.separate_labels();
            }

            // Apply default event emitters & mouse behaviors. Apply to the container, not per element,
            // to reduce number of event listeners. These events will apply to both scatter points and labels.
            this.svg.group
                .on('click.event_emitter', () => {
                    // D3 doesn't natively support bubbling very well; we need to find the data for the bubbled event
                    const item_data = d3.select(d3.event.target).datum();
                    this.parent.emit('element_clicked', item_data, true);
                })
                .call(this.applyBehaviors.bind(this));
        }

        /**
         * A new LD reference variant has been selected (usually by clicking within a GWAS scatter plot)
         *   This event only fires for manually selected variants. It does not fire if the LD reference variant is
         *   automatically selected (eg by choosing the most significant hit in the region)
         * @event set_ldrefvar
         * @property {object} data { ldrefvar } The variant identifier of the LD reference variant
         * @see event:any_lz_event
         */

        /**
         * Method to set a passed element as the LD reference variant in the plot-level state. Triggers a re-render
         *   so that the plot will update with the new LD information.
         * This is useful in tooltips, eg the "make LD reference" action link for GWAS scatter plots.
         * @param {object} element The data associated with a particular plot element
         * @fires event:set_ldrefvar
         * @return {Promise}
          */
        makeLDReference(element) {
            let ref = null;
            if (typeof element == 'undefined') {
                throw new Error('makeLDReference requires one argument of any type');
            } else if (typeof element == 'object') {
                if (this.layout.id_field && typeof element[this.layout.id_field] != 'undefined') {
                    ref = element[this.layout.id_field].toString();
                } else if (typeof element['id'] != 'undefined') {
                    ref = element['id'].toString();
                } else {
                    ref = element.toString();
                }
            } else {
                ref = element.toString();
            }
            this.parent.emit('set_ldrefvar', { ldrefvar: ref }, true);
            return this.parent_plot.applyState({ ldrefvar: ref });
        }
    }

    /**
     * A scatter plot in which the x-axis represents categories, rather than individual positions.
     * For example, this can be used by PheWAS plots to show related groups. This plot allows the categories and color options to be
     *   determined dynamically when data is first loaded.
     * @alias module:LocusZoom_DataLayers~category_scatter
     */
    class CategoryScatter extends Scatter {
        /**
         * @param {string} layout.x_axis.category_field The datum field to use in auto-generating tick marks, color scheme, and point ordering.
         */
        constructor(layout) {
            super(...arguments);
            /**
             * Define category names and extents (boundaries) for plotting.
             * In the form {category_name: [min_x, max_x]}
             * @private
             * @member {Object.<String, Number[]>}
             */
            this._categories = {};
        }

        /**
         * This plot layer makes certain assumptions about the data passed in. Transform the raw array of records from
         *   the datasource to prepare it for plotting, as follows:
         * 1. The scatter plot assumes that all records are given in sequence (pre-grouped by `category_field`)
         * 2. It assumes that all records have an x coordinate for individual plotting
         * @private
         */
        _prepareData() {
            const xField = this.layout.x_axis.field || 'x';
            // The (namespaced) field from `this.data` that will be used to assign datapoints to a given category & color
            const category_field = this.layout.x_axis.category_field;
            if (!category_field) {
                throw new Error(`Layout for ${this.layout.id} must specify category_field`);
            }
            // Sort the data so that things in the same category are adjacent (case-insensitive by specified field)
            const sourceData = this.data
                .sort((a, b) => {
                    const ak = a[category_field];
                    const bk = b[category_field];
                    const av = (typeof ak === 'string') ? ak.toLowerCase() : ak;
                    const bv = (typeof bk === 'string') ? bk.toLowerCase() : bk;
                    return (av === bv) ? 0 : (av < bv ? -1 : 1);
                });
            sourceData.forEach((d, i) => {
                // Implementation detail: Scatter plot requires specifying an x-axis value, and most datasources do not
                //   specify plotting positions. If a point is missing this field, fill in a synthetic value.
                d[xField] = d[xField] || i;
            });
            return sourceData;
        }

        /**
         * Identify the unique categories on the plot, and update the layout with an appropriate color scheme.
         * Also identify the min and max x value associated with the category, which will be used to generate ticks
         * @private
         * @returns {Object.<String, Number[]>} Series of entries used to build category name ticks {category_name: [min_x, max_x]}
         */
        _generateCategoryBounds() {
            // TODO: API may return null values in category_field; should we add placeholder category label?
            // The (namespaced) field from `this.data` that will be used to assign datapoints to a given category & color
            const category_field = this.layout.x_axis.category_field;
            const xField = this.layout.x_axis.field || 'x';
            const uniqueCategories = {};
            this.data.forEach((item) => {
                const category = item[category_field];
                const x = item[xField];
                const bounds = uniqueCategories[category] || [x, x];
                uniqueCategories[category] = [Math.min(bounds[0], x), Math.max(bounds[1], x)];
            });

            const categoryNames = Object.keys(uniqueCategories);
            this._setDynamicColorScheme(categoryNames);

            return uniqueCategories;
        }

        /**
         * This layer relies on defining its own category-based color scheme. Find the correct color config object to
         *  be modified.
         * @param [from_source]
         * @returns {Object} A mutable reference to the layout configuration object
         * @private
         */
        _getColorScale(from_source) {
            from_source = from_source || this.layout;
            // If the layout does not use a supported coloring scheme, or is already complete, this method should do nothing

            // For legacy reasons, layouts can specify color as an object (only one way to set color), as opposed to the
            //  preferred mechanism of array (multiple coloring options)
            let color_params = from_source.color || []; // Object or scalar, no other options allowed
            if (Array.isArray(color_params)) {
                color_params = color_params.find((item) => item.scale_function === 'categorical_bin');
            }
            if (!color_params || color_params.scale_function !== 'categorical_bin') {
                throw new Error('This layer requires that color options be provided as a `categorical_bin`');
            }
            return color_params;
        }

        /**
         * Automatically define a color scheme for the layer based on data returned from the server.
         *   If part of the color scheme has been specified, it will fill in remaining missing information.
         *
         * There are three scenarios:
         * 1. The layout does not specify either category names or (color) values. Dynamically build both based on
         *    the data and update the layout.
         * 2. The layout specifies colors, but not categories. Use that exact color information provided, and dynamically
         *     determine what categories are present in the data. (cycle through the available colors, reusing if there
         *     are a lot of categories)
         * 3. The layout specifies exactly what colors and categories to use (and they match the data!). This is useful to
         *    specify an explicit mapping between color scheme and category names, when you want to be sure that the
         *    plot matches a standard color scheme.
         *    (If the layout specifies categories that do not match the data, the user specified categories will be ignored)
         *
         * This method will only act if the layout defines a `categorical_bin` scale function for coloring. It may be
         *   overridden in a subclass to suit other types of coloring methods.
         *
         * @param {String[]} categoryNames
         * @private
         */
        _setDynamicColorScheme(categoryNames) {
            const colorParams = this._getColorScale(this.layout).parameters;
            const baseParams = this._getColorScale(this._base_layout).parameters;

            if (baseParams.categories.length && baseParams.values.length) {
                // If there are preset category/color combos, make sure that they apply to the actual dataset
                const parameters_categories_hash = {};
                baseParams.categories.forEach((category) => {
                    parameters_categories_hash[category] = 1;
                });
                if (categoryNames.every((name) => Object.prototype.hasOwnProperty.call(parameters_categories_hash, name))) {
                    // The layout doesn't have to specify categories in order, but make sure they are all there
                    colorParams.categories = baseParams.categories;
                } else {
                    colorParams.categories = categoryNames;
                }
            } else {
                colorParams.categories = categoryNames;
            }
            // Prefer user-specified colors if provided. Make sure that there are enough colors for all the categories.
            let colors;
            if (baseParams.values.length) {
                colors = baseParams.values;
            } else {
                // Originally from d3v3 category20
                colors = ['#1f77b4', '#aec7e8', '#ff7f0e', '#ffbb78', '#2ca02c', '#98df8a', '#d62728', '#ff9896', '#9467bd', '#c5b0d5', '#8c564b', '#c49c94', '#e377c2', '#f7b6d2', '#7f7f7f', '#c7c7c7', '#bcbd22', '#dbdb8d', '#17becf', '#9edae5'];
            }
            while (colors.length < categoryNames.length) {
                colors = colors.concat(colors);
            }
            colors = colors.slice(0, categoryNames.length);  // List of hex values, should be of same length as categories array
            colorParams.values = colors;
        }

        /**
         *
         * @param dimension
         * @param {Object} [config] Parameters that customize how ticks are calculated (not style)
         * @param {('left'|'center'|'right')} [config.position='left'] Align ticks with the center or edge of category
         * @returns {Array}
         */
        getTicks(dimension, config) { // Overrides parent method
            if (!['x', 'y1', 'y2'].includes(dimension)) {
                throw new Error('Invalid dimension identifier');
            }
            const position = config.position || 'left';
            if (!['left', 'center', 'right'].includes(position)) {
                throw new Error('Invalid tick position');
            }

            const categoryBounds = this._categories;
            if (!categoryBounds || !Object.keys(categoryBounds).length) {
                return [];
            }

            if (dimension === 'y') {
                return [];
            }

            if (dimension === 'x') {
                // If colors have been defined by this layer, use them to make tick colors match scatterplot point colors
                const colors = this._getColorScale(this.layout);
                const knownCategories = colors.parameters.categories || [];
                const knownColors = colors.parameters.values || [];

                return Object.keys(categoryBounds).map((category, index) => {
                    const bounds = categoryBounds[category];
                    let xPos;

                    switch (position) {
                    case 'left':
                        xPos = bounds[0];
                        break;
                    case 'center':
                        // Center tick under one or many elements as appropriate
                        // eslint-disable-next-line no-case-declarations
                        const diff = bounds[1] - bounds[0];
                        xPos = bounds[0] + (diff !== 0 ? diff : bounds[0]) / 2;
                        break;
                    case 'right':
                        xPos = bounds[1];
                        break;
                    }
                    return {
                        x: xPos,
                        text: category,
                        style: {
                            'fill': knownColors[knownCategories.indexOf(category)] || '#000000',
                        },
                    };
                });
            }
        }

        applyCustomDataMethods() {
            this.data = this._prepareData();
            this._categories = this._generateCategoryBounds();
            return this;
        }
    }

    export { Scatter as scatter, CategoryScatter as category_scatter };