Source: ext/lz-widget-addons.js

/**
 * Optional LocusZoom extension: must be included separately, and after LocusZoom has been loaded
 *
 * This contains (reusable) code to power some (rarely used) demo features:
 *  - The "covariates model" demo, in which an LZ toolbar widget is populated
 *    with information by selecting points on the plot (see "covariates model" demo)
 *  - The "data layers" button, which allows fine control over multiple data layers shown in the same panel
 *    (show/hide, fade, change order, etc). This is powerful, but rarely used because showing many datasets in a small
 *    space makes data hard to see. (see "multiple phenotypes layered" demo)
 *
 * ### Loading and usage
 * The page must incorporate and load all libraries before this file can be used, including:
 * - LocusZoom
 *
 * To use in an environment without special JS build tooling, simply load the extension file as JS from a CDN (after any dependencies):
 *
 * ```javascript
 * <script src="https://cdn.jsdelivr.net/npm/locuszoom@INSERT_VERSION_HERE/dist/ext/lz-widget-addons.min.js" type="application/javascript"></script>
 * ```
 *
 * To use with ES6 modules, the plugin must be loaded and registered explicitly before use:
 * ```javascript
 * import LocusZoom from 'locuszoom';
 * import WidgetAddons from 'locuszoom/esm/ext/lz-widget-addons';
 * LocusZoom.use(WidgetAddons);
 * ```
 *
 * Then use the features made available by this extension. (see demos and documentation for guidance)
 *
 * @module
 */
import {deepCopy} from '../helpers/layouts';

// In order to work in a UMD context, this module imports the top-level LocusZoom symbol

const STATUS_VERBS = ['highlight', 'select', 'fade', 'hide'];
const STATUS_ADJECTIVES = ['highlighted', 'selected', 'faded', 'hidden'];
const STATUS_ANTIVERBS = ['unhighlight', 'deselect', 'unfade', 'show'];


// LocusZoom plugins work by exporting a function that receives the `LocusZoom` object
// This allows them to work in many contexts (including script tags and ES6 imports)
function install(LocusZoom) {
    const _Button = LocusZoom.Widgets.get('_Button');
    const _BaseWidget = LocusZoom.Widgets.get('BaseWidget');


    /**
     * Special button/menu to allow model building by tracking individual covariants. Will track a list of covariate
     *   objects and store them in the special `model.covariates` field of plot `state`.
     *
     * This is a prototype widget for building a conditional analysis model, but it performs no calculation
     *  functionality beyond building a list of items.
     * @alias module:ext/lz-widget-addons~covariates_model
     * @see module:LocusZoom_Widgets~BaseWidget
     * @param {object} layout
     * @param {string} layout.button_html The HTML to render inside the button
     * @param {string} layout.button_title Text to display as a tooltip when hovering over the button
     */
    class CovariatesModel extends _BaseWidget {
        initialize() {
            // Initialize state.model.covariates
            this.parent_plot.state.model = this.parent_plot.state.model || {};
            this.parent_plot.state.model.covariates = this.parent_plot.state.model.covariates || [];
            // Create an object at the plot level for easy access to interface methods in custom client-side JS
            /**
             * When a covariates model toolbar element is present, create (one) object at the plot level that exposes
             *   widget data and state for custom interactions with other plot elements.
             */
            this.parent_plot.CovariatesModel = {
                /** @member {Button} */
                button: this,
                /**
                 * Add an element to the model and show a representation of it in the toolbar widget menu. If the
                 *   element is already part of the model, do nothing (to avoid adding duplicates).
                 * When plot state is changed, this will automatically trigger requests for new data accordingly.
                 * @param {string|object} element_reference Can be any value that can be put through JSON.stringify()
                 *   to create a serialized representation of itself.
                 */
                add: (element_reference) => {
                    const plot = this.parent_plot;
                    const element = deepCopy(element_reference);
                    if (typeof element_reference == 'object' && typeof element.html != 'string') {
                        element.html = ( (typeof element_reference.toHTML == 'function') ? element_reference.toHTML() : element_reference.toString());
                    }
                    // Check if the element is already in the model covariates array and return if it is.
                    for (let i = 0; i < plot.state.model.covariates.length; i++) {
                        if (JSON.stringify(plot.state.model.covariates[i]) === JSON.stringify(element)) {
                            return plot;
                        }
                    }
                    plot.state.model.covariates.push(element);
                    plot.applyState();
                    plot.CovariatesModel.updateWidget();
                    return plot;
                },
                /**
                 * Remove an element from `state.model.covariates` (and from the toolbar widget menu's
                 *  representation of the state model). When plot state is changed, this will automatically trigger
                 *  requests for new data accordingly.
                 * @param {number} idx Array index of the element, in the `state.model.covariates array`.
                 */
                removeByIdx: (idx) => {
                    const plot = this.parent_plot;
                    if (typeof plot.state.model.covariates[idx] == 'undefined') {
                        throw new Error(`Unable to remove model covariate, invalid index: ${idx.toString()}`);
                    }
                    plot.state.model.covariates.splice(idx, 1);
                    plot.applyState();
                    plot.CovariatesModel.updateWidget();
                    return plot;
                },
                /**
                 * Empty the `state.model.covariates` array (and toolbar widget menu representation thereof) of all
                 *  elements. When plot state is changed, this will automatically trigger requests for new data accordingly
                 */
                removeAll: () => {
                    const plot = this.parent_plot;
                    plot.state.model.covariates = [];
                    plot.applyState();
                    plot.CovariatesModel.updateWidget();
                    return plot;
                },
                /**
                 * Manually trigger the update methods on the toolbar widget's button and menu elements to force
                 *   display of most up-to-date content. Can be used to force the toolbar to reflect changes made, eg if
                 *   modifying `state.model.covariates` directly instead of via `plot.CovariatesModel`
                 */
                updateWidget: () => {
                    this.button.update();
                    this.button.menu.update();
                },
            };
        }

        update() {

            if (this.button) {
                return this;
            }

            this.button = new _Button(this)
                .setColor(this.layout.color)
                .setHtml(this.layout.button_html)
                .setTitle(this.layout.button_title)
                .setOnclick(() => {
                    this.button.menu.populate();
                });

            this.button.menu.setPopulate(() => {
                const selector = this.button.menu.inner_selector;
                selector.html('');
                // General model HTML representation
                if (typeof this.parent_plot.state.model.html != 'undefined') {
                    selector.append('div').html(this.parent_plot.state.model.html);
                }
                // Model covariates table
                if (!this.parent_plot.state.model.covariates.length) {
                    selector.append('i').html('no covariates in model');
                } else {
                    selector.append('h5').html(`Model Covariates (${this.parent_plot.state.model.covariates.length})`);
                    const table = selector.append('table');
                    this.parent_plot.state.model.covariates.forEach((covariate, idx) => {
                        const html = ((typeof covariate == 'object' && typeof covariate.html == 'string') ? covariate.html : covariate.toString());
                        const row = table.append('tr');
                        row.append('td').append('button')
                            .attr('class', `lz-toolbar-button lz-toolbar-button-${this.layout.color}`)
                            .style('margin-left', '0em')
                            .on('click', () => this.parent_plot.CovariatesModel.removeByIdx(idx))
                            .html('×');
                        row.append('td')
                            .html(html);
                    });
                    selector.append('button')
                        .attr('class', `lz-toolbar-button lz-toolbar-button-${this.layout.color}`)
                        .style('margin-left', '4px')
                        .html('× Remove All Covariates')
                        .on('click', () => this.parent_plot.CovariatesModel.removeAll());
                }
            });

            this.button.preUpdate = () => {
                let html = 'Model';
                const count = this.parent_plot.state.model.covariates.length;
                if (count) {
                    const noun = count > 1 ? 'covariates' : 'covariate';
                    html += ` (${count} ${noun})`;
                }
                this.button.setHtml(html).disable(false);
            };

            this.button.show();

            return this;
        }
    }


    /**
     * Menu for manipulating multiple data layers in a single panel: show/hide, change order, etc.
     * @alias module:ext/lz-widget-addons~data_layers
     * @see module:LocusZoom_Widgets~BaseWidget
     */
    class DataLayersWidget extends _BaseWidget {
        update() {

            if (typeof this.layout.button_html != 'string') {
                this.layout.button_html = 'Data Layers';
            }
            if (typeof this.layout.button_title != 'string') {
                this.layout.button_title = 'Manipulate Data Layers (sort, dim, show/hide, etc.)';
            }

            if (this.button) {
                return this;
            }

            this.button = new _Button(this)
                .setColor(this.layout.color)
                .setHtml(this.layout.button_html)
                .setTitle(this.layout.button_title)
                .setOnclick(() => {
                    this.button.menu.populate();
                });

            this.button.menu.setPopulate(() => {
                this.button.menu.inner_selector.html('');
                const table = this.button.menu.inner_selector.append('table');
                this.parent_panel._data_layer_ids_by_z_index.slice().reverse().forEach((id, idx) => {
                    const data_layer = this.parent_panel.data_layers[id];
                    const name = (typeof data_layer.layout.name != 'string') ? data_layer.id : data_layer.layout.name;
                    const row = table.append('tr');
                    // Layer name
                    row.append('td').html(name);
                    // Status toggle buttons
                    this.layout.statuses.forEach((status_adj) => {
                        const status_idx = STATUS_ADJECTIVES.indexOf(status_adj);
                        const status_verb = STATUS_VERBS[status_idx];
                        let html, onclick, highlight;
                        if (data_layer._global_statuses[status_adj]) {
                            html = STATUS_ANTIVERBS[status_idx];
                            onclick = `un${status_verb}AllElements`;
                            highlight = '-highlighted';
                        } else {
                            html = STATUS_VERBS[status_idx];
                            onclick = `${status_verb}AllElements`;
                            highlight = '';
                        }
                        row.append('td').append('a')
                            .attr('class', `lz-toolbar-button lz-toolbar-button-${this.layout.color}${highlight}`)
                            .style('margin-left', '0em')
                            .on('click', () => {
                                data_layer[onclick]();
                                this.button.menu.populate();
                            })
                            .html(html);
                    });
                    // Sort layer buttons
                    const at_top = (idx === 0);
                    const at_bottom = (idx === (this.parent_panel._data_layer_ids_by_z_index.length - 1));
                    const td = row.append('td');
                    td.append('a')
                        .attr('class', `lz-toolbar-button lz-toolbar-button-group-start lz-toolbar-button-${this.layout.color}${at_bottom ? '-disabled' : ''}`)
                        .style('margin-left', '0em')
                        .on('click', () => {
                            data_layer.moveBack(); this.button.menu.populate();
                        })
                        .html('▾')
                        .attr('title', 'Move layer down (further back)');
                    td.append('a')
                        .attr('class', `lz-toolbar-button lz-toolbar-button-group-middle lz-toolbar-button-${this.layout.color}${at_top ? '-disabled' : ''}`)
                        .style('margin-left', '0em')
                        .on('click', () => {
                            data_layer.moveForward(); this.button.menu.populate();
                        })
                        .html('▴')
                        .attr('title', 'Move layer up (further front)');
                    td.append('a')
                        .attr('class', 'lz-toolbar-button lz-toolbar-button-group-end lz-toolbar-button-red')
                        .style('margin-left', '0em')
                        .on('click', () => {
                            if (confirm(`Are you sure you want to remove the ${name} layer? This cannot be undone.`)) {
                                data_layer.parent.removeDataLayer(id);
                            }
                            return this.button.menu.populate();
                        })
                        .html('×')
                        .attr('title', 'Remove layer');
                });
                return this;
            });

            this.button.show();

            return this;
        }
    }

    const covariates_model_tooltip = function () {
        const covariates_model_association = LocusZoom.Layouts.get('tooltip', 'standard_association');
        covariates_model_association.html += '<a href="javascript:void(0);" onclick="LocusZoom.getToolTipPlot(this).CovariatesModel.add(LocusZoom.getToolTipData(this));">Condition on Variant</a><br>';
        return covariates_model_association;
    }();

    const covariates_model_plot = function () {
        const covariates_model_plot_toolbar = LocusZoom.Layouts.get('toolbar', 'standard_association');
        covariates_model_plot_toolbar.widgets.push({
            type: 'covariates_model',
            button_html: 'Model',
            button_title: 'Show and edit covariates currently in model',
            position: 'left',
        });
        return covariates_model_plot_toolbar;
    }();

    LocusZoom.Widgets.add('covariates_model', CovariatesModel);
    LocusZoom.Widgets.add('data_layers', DataLayersWidget);

    LocusZoom.Layouts.add('tooltip', 'covariates_model_association', covariates_model_tooltip);
    LocusZoom.Layouts.add('toolbar', 'covariates_model_plot', covariates_model_plot);
}

if (typeof LocusZoom !== 'undefined') {
    // Auto-register the plugin when included as a script tag. ES6 module users must register via LocusZoom.use()
    // eslint-disable-next-line no-undef
    LocusZoom.use(install);
}


export default install;