/**
* 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;