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