Source: helpers/common.js

/**
 * @module
 * @private
 */
// FIXME: A place for code that used to live under the `LocusZoom` namespace
// Eventually this should be moved into classes or some other mechanism for code sharing. No external uses should
//  depend on any items in this module.

import * as d3 from 'd3';

/**
 * Generate a curtain object for a plot, panel, or any other subdivision of a layout
 * The panel curtain, like the plot curtain is an HTML overlay that obscures the entire panel. It can be styled
 *   arbitrarily and display arbitrary messages. It is useful for reporting error messages visually to an end user
 *   when the error renders the panel unusable.
 *   TODO: Improve type doc here
 * @returns {object}
 */
function generateCurtain() {
    return {
        showing: false,
        selector: null,
        content_selector: null,
        hide_delay: null,

        /**
         * Generate the curtain. Any content (string) argument passed will be displayed in the curtain as raw HTML.
         *   CSS (object) can be passed which will apply styles to the curtain and its content.
         * @param {string} content Content to be displayed on the curtain (as raw HTML)
         * @param {object} css Apply the specified styles to the curtain and its contents
         */
        show: (content, css) => {
            if (!this.curtain.showing) {
                this.curtain.selector = d3.select(this.parent_plot.svg.node().parentNode).insert('div')
                    .attr('class', 'lz-curtain')
                    .attr('id', `${this.id}.curtain`);
                this.curtain.content_selector = this.curtain.selector.append('div')
                    .attr('class', 'lz-curtain-content');
                this.curtain.selector.append('div')
                    .attr('class', 'lz-curtain-dismiss').html('Dismiss')
                    .on('click', () => this.curtain.hide());
                this.curtain.showing = true;
            }
            return this.curtain.update(content, css);
        },

        /**
         * Update the content and css of the curtain that's currently being shown. This method also adjusts the size
         *   and positioning of the curtain to ensure it still covers the entire panel with no overlap.
         * @param {string} content Content to be displayed on the curtain (as raw HTML)
         * @param {object} css Apply the specified styles to the curtain and its contents
         */
        update: (content, css) => {
            if (!this.curtain.showing) {
                return this.curtain;
            }
            clearTimeout(this.curtain.hide_delay);
            // Apply CSS if provided
            if (typeof css == 'object') {
                applyStyles(this.curtain.selector, css);
            }
            // Update size and position
            const page_origin = this._getPageOrigin();

            // Panel layouts have a height; plot layouts don't
            const height = this.layout.height || this._total_height;
            this.curtain.selector
                .style('top', `${page_origin.y}px`)
                .style('left', `${page_origin.x}px`)
                .style('width', `${this.parent_plot.layout.width}px`)
                .style('height', `${height}px`);
            this.curtain.content_selector
                .style('max-width', `${this.parent_plot.layout.width - 40}px`)
                .style('max-height', `${height - 40}px`);
            // Apply content if provided
            if (typeof content == 'string') {
                this.curtain.content_selector.html(content);
            }
            return this.curtain;
        },

        /**
         * Remove the curtain
         * @param {number} delay Time to wait (in ms)
         */
        hide: (delay) => {
            if (!this.curtain.showing) {
                return this.curtain;
            }
            // If a delay was passed then defer to a timeout
            if (typeof delay == 'number') {
                clearTimeout(this.curtain.hide_delay);
                this.curtain.hide_delay = setTimeout(this.curtain.hide, delay);
                return this.curtain;
            }
            // Remove curtain
            this.curtain.selector.remove();
            this.curtain.selector = null;
            this.curtain.content_selector = null;
            this.curtain.showing = false;
            return this.curtain;
        },
    };
}

/**
 * Generate a loader object for a plot, panel, or any other subdivision of a layout
 *
 * The panel loader is a small HTML overlay that appears in the lower left corner of the panel. It cannot be styled
 *   arbitrarily, but can show a custom message and show a minimalist loading bar that can be updated to specific
 *   completion percentages or be animated.
 * TODO Improve type documentation
 * @returns {object}
 */
function generateLoader() {
    return {
        showing: false,
        selector: null,
        content_selector: null,
        progress_selector: null,
        cancel_selector: null,

        /**
         * Show a loading indicator
         * @param {string} [content='Loading...'] Loading message (displayed as raw HTML)
         */
        show: (content) => {
            // Generate loader
            if (!this.loader.showing) {
                this.loader.selector = d3.select(this.parent_plot.svg.node().parentNode).insert('div')
                    .attr('class', 'lz-loader')
                    .attr('id', `${this.id}.loader`);
                this.loader.content_selector = this.loader.selector.append('div')
                    .attr('class', 'lz-loader-content');
                this.loader.progress_selector = this.loader.selector
                    .append('div')
                    .attr('class', 'lz-loader-progress-container')
                    .append('div')
                    .attr('class', 'lz-loader-progress');

                this.loader.showing = true;
                if (typeof content == 'undefined') {
                    content = 'Loading...';
                }
            }
            return this.loader.update(content);
        },

        /**
         * Update the currently displayed loader and ensure the new content is positioned correctly.
         * @param {string} content The text to display (as raw HTML). If not a string, will be ignored.
         * @param {number} [percent] A number from 1-100. If a value is specified, it will stop all animations
         *   in progress.
         */
        update: (content, percent) => {
            if (!this.loader.showing) {
                return this.loader;
            }
            clearTimeout(this.loader.hide_delay);
            // Apply content if provided
            if (typeof content == 'string') {
                this.loader.content_selector.html(content);
            }
            // Update size and position
            const padding = 6; // is there a better place to store/define this?
            const page_origin = this._getPageOrigin();
            const loader_boundrect = this.loader.selector.node().getBoundingClientRect();
            this.loader.selector
                .style('top', `${page_origin.y + this.layout.height - loader_boundrect.height - padding}px`)
                .style('left', `${page_origin.x + padding  }px`);

            // Apply percent if provided
            if (typeof percent == 'number') {
                this.loader.progress_selector
                    .style('width', `${Math.min(Math.max(percent, 1), 100)}%`);
            }
            return this.loader;
        },

        /**
         * Adds a class to the loading bar that makes it loop infinitely in a loading animation. Useful when exact
         *   percent progress is not available.
         */
        animate: () => {
            this.loader.progress_selector.classed('lz-loader-progress-animated', true);
            return this.loader;
        },

        /**
         *  Sets the loading bar in the loader to percentage width equal to the percent (number) value passed. Percents
         *    will automatically be limited to a range of 1 to 100. Will stop all animations in progress.
         */
        setPercentCompleted: (percent) => {
            this.loader.progress_selector.classed('lz-loader-progress-animated', false);
            return this.loader.update(null, percent);
        },

        /**
         * Remove the loader
         * @param {number} delay Time to wait (in ms)
         */
        hide: (delay) => {
            if (!this.loader.showing) {
                return this.loader;
            }
            // If a delay was passed then defer to a timeout
            if (typeof delay == 'number') {
                clearTimeout(this.loader.hide_delay);
                this.loader.hide_delay = setTimeout(this.loader.hide, delay);
                return this.loader;
            }
            // Remove loader
            this.loader.selector.remove();
            this.loader.selector = null;
            this.loader.content_selector = null;
            this.loader.progress_selector = null;
            this.loader.cancel_selector = null;
            this.loader.showing = false;
            return this.loader;
        },
    };
}

/**
 * Modern d3 removed the ability to set many styles at once (object syntax). This is a helper so that layouts with
 *  config-objects can set styles all at once
 * @private
 * @param {d3.selection} selection
 * @param {Object} styles
 */
function applyStyles(selection, styles) {
    styles = styles || {};
    for (let [prop, value] of Object.entries(styles)) {
        selection.style(prop, value);
    }
}

/**
 * Prevent a UI function from being called more than once in a given interval. This allows, eg, search boxes to delay
 *   expensive operations until the user is done typing
 * @param {function} func The function to debounce. Returns a wrapper.
 * @param {number} delay Time to wait after last call (in ms)
 */
function debounce(func, delay = 500) {
    let timer;
    return () => {
        clearTimeout(timer);
        timer = setTimeout(
            () => func.apply(this, arguments),
            delay
        );
    };
}

export { applyStyles, debounce, generateCurtain, generateLoader };