Source: ext/lz-forest-track.js

/**
 * Forest plot track, designed for use with PheWAS style datasets.
 *   This is not part of the core LocusZoom library, but can be included as a standalone file.
 *
 * ### Features provided
 * * {@link module:LocusZoom_DataLayers~forest}
 * * {@link module:LocusZoom_DataLayers~category_forest}
 *
 * ### 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):
 * ```
 * <script src="https://cdn.jsdelivr.net/npm/locuszoom@INSERT_VERSION_HERE/dist/ext/lz-forest-track.min.js" type="application/javascript"></script>
 * ```
 *
 * To use with ES6 modules, the plugin must be loaded and registered explicitly before use:
 * ```
 * import LocusZoom from 'locuszoom';
 * import ForestTrack from 'locuszoom/esm/ext/lz-forest-track';
 * LocusZoom.use(ForestTrack);
 * ```
 *
 * Then use the layouts made available by this extension. (see demos and documentation for guidance)
 *
 * @module
 */
import * as d3 from 'd3';


function install (LocusZoom) {
    const BaseDataLayer = LocusZoom.DataLayers.get('BaseDataLayer');
    const default_layout = {
        point_size: 40,
        point_shape: 'square',
        color: '#888888',
        fill_opacity: 1,
        y_axis: {
            axis: 2,
        },
        id_field: 'id',
        confidence_intervals: {
            start_field: 'ci_start',
            end_field: 'ci_end',
        },
    };

    /**
     * (**extension**) Forest Data Layer
     * Implements a standard forest plot. In order to space out points, any layout using this must specify axis ticks
     *  and extent in advance.
     *
     * If you are using dynamically fetched data, consider using `category_forest` instead.
     * @alias module:LocusZoom_DataLayers~forest
     * @see module:LocusZoom_DataLayers~BaseDataLayer
     * @see {@link module:ext/lz-forest-track} for required extension and installation instructions
     */
    class Forest 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='square'] 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 each point
         * @param {number|module:LocusZoom_DataLayers~ScalableParameter[]} [layout.fill_opacity=1] Opacity (0..1) for each datum point
         * @param {string} layout.x_axis.field A field specifying the x-coordinate of the mark (eg square)
         * @param {string} layout.y_axis.field A field specifying the y-coordinate. Use `category_forest` if you just want to
         *  lay out a series of forest markings in order without worrying about this.
         * @param [layout.confidence_intervals.start_field='ci_start'] The field that specifies the start of confidence interval
         * @param [layout.confidence_intervals.end_field='ci_end'] The field that specifies the start of confidence interval
         */
        constructor(layout) {
            layout = LocusZoom.Layouts.merge(layout, default_layout);
            super(...arguments);
        }

        _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,
            };
        }

        /**
         * @fires event:element_clicked
         */
        render() {
            // Apply filters to only render a specified set of points
            const track_data = this._applyFilters();

            const x_scale = 'x_scale';
            const y_scale = `y${this.layout.y_axis.axis}_scale`;

            // Generate confidence interval paths if fields are defined
            if (this.layout.confidence_intervals &&
                this.layout.confidence_intervals.start_field &&
                this.layout.confidence_intervals.end_field) {
                // Generate a selection for all forest plot confidence intervals
                const ci_selection = this.svg.group
                    .selectAll('rect.lz-data_layer-forest.lz-data_layer-forest-ci')
                    .data(track_data, (d) => {
                        return d[this.layout.id_field];
                    });

                const ci_transform = (d) => {
                    let x = this.parent[x_scale](d[this.layout.confidence_intervals.start_field]);
                    let y = this.parent[y_scale](d[this.layout.y_axis.field]);
                    if (isNaN(x)) {
                        x = -1000;
                    }
                    if (isNaN(y)) {
                        y = -1000;
                    }
                    return `translate(${x}, ${y})`;
                };
                const ci_width = (d) => {
                    const {start_field, end_field} = this.layout.confidence_intervals;
                    const scale = this.parent[x_scale];
                    const result =  scale(d[end_field]) - scale(d[start_field]);
                    return Math.max(result, 1);
                };
                const ci_height = 1;
                // Create confidence interval rect elements
                ci_selection.enter()
                    .append('rect')
                    .attr('class', 'lz-data_layer-forest lz-data_layer-forest-ci')
                    .attr('id', (d) => `${this.getElementId(d)}_ci`)
                    .attr('transform', `translate(0, ${isNaN(this.parent.layout.height) ? 0 : this.parent.layout.height})`)
                    .merge(ci_selection)
                    .attr('transform', ci_transform)
                    .attr('width', ci_width) // Math.max(ci_width, 1))
                    .attr('height', ci_height);

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

            // Generate a selection for all forest plot points
            const points_selection = this.svg.group
                .selectAll('path.lz-data_layer-forest.lz-data_layer-forest-point')
                .data(track_data, (d) => {
                    return d[this.layout.id_field];
                });

            // Create elements, apply class, ID, and initial position
            const initial_y = isNaN(this.parent.layout.height) ? 0 : this.parent.layout.height;

            // Generate new values (or functions for them) for position, color, size, and shape
            const transform = (d) => {
                let x = this.parent[x_scale](d[this.layout.x_axis.field]);
                let y = this.parent[y_scale](d[this.layout.y_axis.field]);
                if (isNaN(x)) {
                    x = -1000;
                }
                if (isNaN(y)) {
                    y = -1000;
                }
                return `translate(${x}, ${y})`;
            };

            const fill = (d, i) => this.resolveScalableParameter(this.layout.color, d, i);
            const fill_opacity = (d, i) => this.resolveScalableParameter(this.layout.fill_opacity, d, i);

            const shape = d3.symbol()
                .size((d, i) => this.resolveScalableParameter(this.layout.point_size, d, i))
                .type((d, i) => {
                    // Legend shape names are strings; need to connect this to factory. Eg circle --> d3.symbolCircle
                    const shape_name = this.resolveScalableParameter(this.layout.point_shape, d, i);
                    const factory_name = `symbol${shape_name.charAt(0).toUpperCase() + shape_name.slice(1)}`;
                    return d3[factory_name] || null;
                });

            points_selection.enter()
                .append('path')
                .attr('class', 'lz-data_layer-forest lz-data_layer-forest-point')
                .attr('id', (d) => this.getElementId(d))
                .attr('transform', `translate(0, ${initial_y})`)
                .merge(points_selection)
                .attr('transform', transform)
                .attr('fill', fill)
                .attr('fill-opacity', fill_opacity)
                .attr('d', shape);

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

            // Apply behaviors to points
            this.svg.group
                .on('click.event_emitter', (element_data) => {
                    this.parent.emit('element_clicked', element_data, true);
                }).call(this.applyBehaviors.bind(this));
        }
    }

    /**
     * (**extension**) A y-aligned forest plot in which the y-axis represents item labels, which are dynamically
     *   chosen when data is loaded. Each item is assumed to include both data and confidence intervals.
     *   This allows generating forest plots without defining the layout in advance.
     * @alias module:LocusZoom_DataLayers~category_forest
     * @see module:LocusZoom_DataLayers~BaseDataLayer
     * @see {@link module:ext/lz-forest-track} for required extension and installation instructions
     */
    class CategoryForest extends Forest {
        _getDataExtent(data, axis_config) {
            // In a forest plot, the data range is determined by *three* fields (beta + CI start/end)
            const { confidence_intervals } = this.layout;
            if (confidence_intervals && confidence_intervals.start_field && confidence_intervals.end_field) {
                const min = (d) => +d[confidence_intervals.start_field];
                const max = (d) => +d[confidence_intervals.end_field];
                return [d3.min(data, min), d3.max(data, max)];
            }

            // If there are no confidence intervals set, then range must depend only on a single field
            return super._getDataExtent(data, axis_config);
        }

        getTicks(dimension, config) { // Overrides parent method
            if (!['x', 'y1', 'y2'].includes(dimension)) {
                throw new Error(`Invalid dimension identifier ${dimension}`);
            }

            // Design assumption: one axis (y1 or y2) has the ticks, and the layout says which to use
            // Also assumes that every tick gets assigned a unique matching label
            const axis_num = this.layout.y_axis.axis;
            if (dimension === (`y${axis_num}`)) {
                const category_field = this.layout.y_axis.category_field;
                if (!category_field) {
                    throw new Error(`Layout for ${this.layout.id} must specify category_field`);
                }

                return this.data.map((item, index) => ({ y: index + 1, text: item[category_field] }));
            } else {
                return [];
            }
        }

        applyCustomDataMethods () {
            // Add a synthetic yaxis field to ensure data is spread out on plot. Then, set axis floor and ceiling to
            //  correct extents.
            const field_to_add = this.layout.y_axis.field;
            if (!field_to_add) {
                throw new Error(`Layout for ${this.layout.id} must specify yaxis.field`);
            }

            this.data = this.data.map((item, index) => {
                item[field_to_add] = index + 1;
                return item;
            });
            // Update axis extents based on one label for every point (with a bit of padding above and below)
            this.layout.y_axis.floor = 0;
            this.layout.y_axis.ceiling = this.data.length + 1;
            return this;
        }
    }

    LocusZoom.DataLayers.add('forest', Forest);
    LocusZoom.DataLayers.add('category_forest', CategoryForest);
}

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;