/**
* Data layers represent instructions for how to render common types of information.
* (GWAS scatter plot, nearby genes, straight lines and filled curves, etc)
*
* Each rendering type also provides helpful functionality such as filtering, matching, and interactive tooltip
* display. Predefined layers can be extended or customized, with many configurable options.
*
* @module LocusZoom_DataLayers
*/
import * as d3 from 'd3';
import {STATUSES} from '../constants';
import Field from '../../data/field';
import {parseFields} from '../../helpers/display';
import {deepCopy, findFields, merge} from '../../helpers/layouts';
import MATCHERS from '../../registry/matchers';
import SCALABLE from '../../registry/scalable';
/**
* "Scalable" parameters indicate that a datum can be rendered in custom ways based on its value. (color, size, shape, etc)
*
* This means that if the value of this property is a scalar, it is used directly (`color: '#FF0000'`). But if the
* value is an array of options, each will be evaluated in turn until the first non-null result is found. The syntax
* below describes how each member of the array should specify the field and scale function to be used.
* Often, the last item in the list is a string, providing a "default" value if all scale functions evaluate to null.
*
* @typedef {object[]|string} ScalableParameter
* @property {string} [field] The name of the field to use in the scale function. If omitted, all fields for the given
* datum element will be passed to the scale function.
* @property {module:LocusZoom_ScaleFunctions} scale_function The name of a scale function that will be run on each individual datum
* @property {object} parameters A set of parameters that configure the desired scale function (options vary by function)
*/
/**
* @typedef {Object} module:LocusZoom_DataLayers~behavior
* @property {'set'|'unset'|'toggle'|'link'} action
* @property {'highlighted'|'selected'|'faded'|'hidden'} status An element display status to set/unset/toggle
* @property {boolean} exclusive Whether an element status should be exclusive (eg only allow one point to be selected at a time)
* @property {string} href For links, the URL to visit when clicking
* @property {string} target For links, the `target` attribute (eg, name of a window or tab in which to open this link)
*/
/**
* @typedef {object} FilterOption
* @property {string} field The name of a field found within each datapoint datum
* @property {module:LocusZoom_MatchFunctions} operator The name of a comparison function to use when deciding if the
* field satisfies this filter
* @property value The target value to compare to
*/
/**
* @typedef {object} DataOperation A synchronous function that modifies data returned from adapters, in order to clean up or reformat prior to plotting.
* @property {module:LocusZoom_DataFunctions|'fetch'} type
* @property {String[]} [from] For operations of type "fetch", this is required. By default, it will fill in any items provided in "namespace" (everything specified in namespace triggers an adapter/network request)
* A namespace should be manually specified in this array when there are dependencies (one request depends on the content of what is returned from another namespace).
* Eg, for ld to be fetched after association data, specify "ld(assoc)". Most LocusZoom examples fill in all items, in order to make the examples more clear.
* @property {String} [name] The name of this operation. This only needs to be specified if a data layer performs several operations, and needs to refer to the result of a prior operation.
* Eg, if the retrieved data is combined via several left joins in series: `name: "assoc_plus_ld"` -> feeds into `{name: "final", requires: ["assoc_plus_ld"]}`
* @property {String[]} requires The names of each adapter required. This does not need to specify dependencies, just
* the names of other namespaces (or data operations) whose results must be available before a join can be performed
* @property {String[]} params Any user-defined parameters that should be passed to the particular join function
* (see: {@link module:LocusZoom_DataFunctions} for details). For example, this could specify the left and right key fields for a join function, based on the expected field names used in this data layer: `params: ['assoc:position', 'ld:position2']`
* Separate from this section, data functions will also receive a copy of "plot.state" automatically.
*/
/**
* @typedef {object} LegendItem
* @property [shape] This is optional (e.g. a legend element could just be a textual label).
* Supported values are the standard d3 3.x symbol types (i.e. "circle", "cross", "diamond", "square",
* "triangle-down", and "triangle-up"), as well as "rect" for an arbitrary square/rectangle or "line" for a path.
* A special "ribbon" option can be use to draw a series of explicit, numeric-only color stops (a row of colored squares, such as to indicate LD)
* @property {string} color The point color (hexadecimal, rgb, etc)
* @property {string} label The human-readable label of the legend item
* @property {string} [class] The name of a CSS class used to style the point in the legend
* @property {number} [size] The point area for each element (if the shape is a d3 symbol). Eg, for a 40 px area,
* a circle would be ~7..14 px in diameter.
* @property {number} [length] Length (in pixels) for the path rendered as the graphical portion of the legend element
* if the value of the shape parameter is "line".
* @property {number} [width] Width (in pixels) for the rect rendered as the graphical portion of the legend element if
* the value of the shape parameter is "rect" or "ribbon".
* @property {number} [height] Height (in pixels) for the rect rendered as the graphical portion of the legend element if
* the value of the shape parameter is "rect" or "ribbon".
* @property {'vertical'|'horizontal'} [orientation='vertical'] For shape "ribbon", specifies whether to draw the ribbon vertically or horizontally.
* @property {Array} [tick_labels] For shape "ribbon", specifies the tick labels that correspond to each colorstop value. Tick labels appear at all box edges: this array should have 1 more item than the number of colorstops.
* @property {String[]} [color_stops] For shape "ribbon", specifies the colors of each box in the row of colored squares. There should be 1 fewer item in color_stops than the number of tick labels.
* @property {object} style CSS styles object to be applied to the DOM element representing the graphical portion of
* the legend element.
*/
/**
* A basic description of keys expected in all data layer layouts. Not intended to be directly used or modified by an end user.
* @memberof module:LocusZoom_DataLayers~BaseDataLayer
* @protected
*/
const default_layout = {
id: '',
type: '',
tag: 'custom_data_type',
namespace: {},
data_operations: [],
id_field: 'id',
filters: null,
match: {},
x_axis: {},
y_axis: {}, // Axis options vary based on data layer type
legend: null,
tooltip: {},
tooltip_positioning: 'horizontal', // Where to draw tooltips relative to the point. Can be "vertical" or "horizontal"
behaviors: {},
};
/**
* A data layer is an abstract class representing a data set and its graphical representation within a panel
* @public
*/
class BaseDataLayer {
/**
* @param {string} [layout.id=''] An identifier string that must be unique across all layers within the same panel
* @param {string} [layout.type=''] The type of data layer. This parameter is used in layouts to specify which class
* (from the registry) is created; it is also used in CSS class names.
* @param {string} [layout.tag='custom_data_type'] Tags have no functional purpose, but they can be used
* as a semantic label for what is being displayed in this element. This makes it easy to write custom code like "find every data
* layer that shows association scatter plots, anywhere": even if the IDs are different, the tag can be the same.
* Most built-in data layers will contain a tag that describes, in human-readable terms, what kind of data is being shown.
* (see: {@link LayoutRegistry.mutate_attrs})
* @param {string} [layout.id_field] The datum field used for unique element IDs when addressing DOM elements, mouse
* events, etc. This should be unique to the specified datum, and persistent across re-renders (because it is
* used to identify where to draw tooltips, eg, if the plot is dragged or zoomed). If no single field uniquely
* identifies all items, a template expression can be used to create an ID from multiple fields instead. (it is
* your job to assure that all of the expected fields are present in every element)
* @param {object} layout.namespace A set of key value pairs representing how to map the local usage of data ("assoc")
* to the globally unique name for something defined in LocusZoom.DataSources.
* Namespaces allow a single layout to be reused to plot many tracks of the same type: "given some form of association data, plot it".
* These pairs take the form of { local_name: global_name }. (and all data layer layouts provide a default) In order to reuse
* a layout with a new provider of data- like plotting two association studies stacked together-
* only the namespace section of the layout needs to be overridden.
* Eg, `LocusZoom.Layouts.get('data_layers', 'association_pvalues', { namespace: { assoc: 'assoc_study_2' }})`
* @param {module:LocusZoom_DataLayers~DataOperation[]} layout.data_operations A set of data operations that will be performed on the data returned from the data adapters specified in `namespace`. (see: {@link module:LocusZoom_DataFunctions})
* @param {module:LocusZoom_DataLayers~FilterOption[]} [layout.filters] If present, restricts the list of data elements to be displayed. Typically, filters
* hide elements, but arrange the layer so as to leave the space those elements would have occupied. The exact
* details vary from one layer to the next. See the Interactivity Tutorial for details.
* @param {object} [layout.match] An object describing how to connect this data layer to other data layers in the
* same plot. Specifies keys `send` and `receive` containing the names of fields with data to be matched;
* `operator` specifies the name of a MatchFunction to use. If a datum matches the broadcast value, it will be
* marked with the special field `lz_is_match=true`, which can be used in any scalable layout directive to control how the item is rendered.
* @param {boolean} [layout.x_axis.decoupled=false] If true, the data in this layer will not influence the x-extent of the panel.
* @param {'state'|null} [layout.x_axis.extent] If provided, the region plot x-extent will be determined from
* `plot.state` rather than from the range of the data. This is the most common way of setting x-extent,
* as it is useful for drawing a set of panels to reflect a particular genomic region.
* @param {number} [layout.x_axis.floor] The low end of the x-extent, which overrides any actual data range, min_extent, or buffer options.
* @param {number} [layout.x_axis.ceiling] The high end of the x-extent, which overrides any actual data range, min_extent, or buffer options.
* @param {Number[]} [layout.x_axis.min_extent] The smallest possible range [min, max] of the x-axis. If the actual values lie outside the extent, the actual data takes precedence.
* @param {number} [layout.x_axis.field] The datum field to look at when determining data extent along the x-axis.
* @param {number} [layout.x_axis.lower_buffer] Amount to expand (pad) the lower end of an axis as a proportion of the extent of the data.
* @param {number} [layout.x_axis.upper_buffer] Amount to expand (pad) the higher end of an axis as a proportion of the extent of the data.
* @param {boolean} [layout.y_axis.decoupled=false] If true, the data in this layer will not influence the y-extent of the panel.
* @param {object} [layout.y_axis.axis=1] Which y axis to use for this data layer (left=1, right=2)
* @param {number} [layout.y_axis.floor] The low end of the y-extent, which overrides any actual data range, min_extent, or buffer options.
* @param {number} [layout.y_axis.ceiling] The high end of the y-extent, which overrides any actual data range, min_extent, or buffer options.
* @param {Number[]} [layout.y_axis.min_extent] The smallest possible range [min, max] of the y-axis. Actual lower or higher data values will take precedence.
* @param {number} [layout.y_axis.field] The datum field to look at when determining data extent along the y-axis.
* @param {number} [layout.y_axis.lower_buffer] Amount to expand (pad) the lower end of an axis as a proportion of the extent of the data.
* @param {number} [layout.y_axis.upper_buffer] Amount to expand (pad) the higher end of an axis as a proportion of the extent of the data.
* @param {object} [layout.tooltip.show] Define when to show a tooltip in terms of interaction states, eg, `{ or: ['highlighted', 'selected'] }`
* @param {object} [layout.tooltip.hide] Define when to hide a tooltip in terms of interaction states, eg, `{ and: ['unhighlighted', 'unselected'] }`
* @param {boolean} [layout.tooltip.closable] Whether a tool tip should render a "close" button in the upper right corner.
* @param {string} [layout.tooltip.html] HTML template to render inside the tool tip. The template syntax uses curly braces to allow simple expressions:
* eg `{{sourcename:fieldname}} to insert a field value from the datum associated with
* the tooltip/element. Conditional tags are supported using the format:
* `{{#if sourcename:fieldname|transforms_can_be_used_too}}render text here{{#else}}Optional else branch{{/if}}`.
* @param {'horizontal'|'vertical'|'top'|'bottom'|'left'|'right'} [layout.tooltip_positioning='horizontal'] Where to draw the tooltip relative to the datum.
* Typically tooltip positions are centered around the midpoint of the data element, subject to overflow off the edge of the plot.
* @param {object} [layout.behaviors] LocusZoom data layers support the binding of mouse events to one or more
* layout-definable behaviors. Some examples of behaviors include highlighting an element on mouseover, or
* linking to a dynamic URL on click, etc.
* @param {module:LocusZoom_DataLayers~LegendItem[]} [layout.legend] Tick marks found in the panel legend
* @param {module:LocusZoom_DataLayers~behavior[]} [layout.behaviors.onclick]
* @param {module:LocusZoom_DataLayers~behavior[]} [layout.behaviors.onctrlclick]
* @param {module:LocusZoom_DataLayers~behavior[]} [layout.behaviors.onctrlshiftclick]
* @param {module:LocusZoom_DataLayers~behavior[]} [layout.behaviors.onshiftclick]
* @param {module:LocusZoom_DataLayers~behavior[]} [layout.behaviors.onmouseover]
* @param {module:LocusZoom_DataLayers~behavior[]} [layout.behaviors.onmouseout]
* @param {Panel|null} parent Where this layout is used
*/
constructor(layout, parent) {
/**
* @private
* @member {Boolean}
*/
this._initialized = false;
/**
* @private
* @member {Number}
*/
this._layout_idx = null;
/**
* The unique identifier for this layer. Should be unique within this panel.
* @public
* @member {String}
*/
this.id = null;
/**
* The fully qualified identifier for the data layer, prefixed by any parent or container elements.
* @type {string}
* @private
*/
this._base_id = null;
/**
* @protected
* @member {Panel}
*/
this.parent = parent || null;
/**
* @private
* @member {{group: d3.selection, container: d3.selection, clipRect: d3.selection}}
*/
this.svg = {};
/**
* @protected
* @member {Plot}
*/
this.parent_plot = null;
if (parent) {
this.parent_plot = parent.parent;
}
/**
* The current layout configuration for this data layer. This reflects any resizing or dynamically generated
* config options produced during rendering. Direct layout mutations are a powerful way to dynamically
* modify the plot in response to user interactions, but require a deep knowledge of LZ internals to use
* effectively.
* @public
* @member {Object}
*/
this.layout = merge(layout || {}, default_layout);
if (this.layout.id) {
this.id = this.layout.id;
}
/**
* A user-provided function used to filter data for display. If provided, this will override any declarative
* options in `layout.filters`
* @private
* @deprecated
*/
this._filter_func = null;
// Ensure any axes defined in the layout have an explicit axis number (default: 1)
if (this.layout.x_axis !== {} && typeof this.layout.x_axis.axis !== 'number') {
// TODO: Example of x2? if none remove
this.layout.x_axis.axis = 1;
}
if (this.layout.y_axis !== {} && typeof this.layout.y_axis.axis !== 'number') {
this.layout.y_axis.axis = 1;
}
/**
* Values in the layout object may change during rendering etc. Retain a copy of the original data layer state.
* This is useful for, eg, dynamically generated color schemes that need to start from scratch when new data is
* loaded: it contains the "defaults", not just the result of a calculated value.
* @ignore
* @protected
* @member {Object}
*/
this._base_layout = deepCopy(this.layout);
/**
* @private
* @member {Object}
*/
this.state = {};
/**
* @private
* @member {String}
*/
this._state_id = null;
/**
* @private
* @member {Object}
* */
this._layer_state = null;
// Create a default state (and set any references to the parent as appropriate)
this._setDefaultState();
// Initialize parameters for storing data and tool tips
/**
* The data retrieved from a region request. This field is useful for debugging, but will be overridden on
* re-render; do not modify it directly. The point annotation cache can be used to preserve markings
* after re-render.
* @protected
* @member {Array}
*/
this.data = [];
if (this.layout.tooltip) {
/**
* @private
* @member {Object}
*/
this._tooltips = {};
}
// Initialize flags for tracking global statuses
this._global_statuses = {
'highlighted': false,
'selected': false,
'faded': false,
'hidden': false,
};
// On first load, pre-parse the data specification once, so that it can be used for all other data retrieval
this._data_contract = new Set(); // List of all fields requested by the layout
this._entities = new Map();
this._dependencies = [];
this.mutateLayout(); // Parse data spec and any other changes that need to reflect the layout
}
/****** Public interface: methods for manipulating the layer from other parts of LZ */
/**
* @public
*/
render() {
throw new Error('Method must be implemented');
}
/**
* Move a data layer forward relative to others by z-index
* @public
* @returns {BaseDataLayer}
*/
moveForward() {
const layer_order = this.parent._data_layer_ids_by_z_index;
const current_index = this.layout.z_index;
if (layer_order[current_index + 1]) {
layer_order[current_index] = layer_order[current_index + 1];
layer_order[current_index + 1] = this.id;
this.parent.resortDataLayers();
}
return this;
}
/**
* Move a data layer back relative to others by z-index
* @public
* @returns {BaseDataLayer}
*/
moveBack() {
const layer_order = this.parent._data_layer_ids_by_z_index;
const current_index = this.layout.z_index;
if (layer_order[current_index - 1]) {
layer_order[current_index] = layer_order[current_index - 1];
layer_order[current_index - 1] = this.id;
this.parent.resortDataLayers();
}
return this;
}
/**
* Set an "annotation": a piece of additional information about a point that is preserved across re-render,
* or as the user pans and zooms near this region.
*
* Annotations can be referenced as a named pseudo-field in any filters and scalable parameters. (template support
* may be added in the future)
* Sample use case: user clicks a tooltip to "label this specific point". (or change any other display property)
*
* @public
* @param {String|Object} element The data object or ID string for the element
* @param {String} key The name of the annotation to track
* @param {*} value The value of the marked field
*/
setElementAnnotation (element, key, value) {
const id = this.getElementId(element);
if (!this._layer_state.extra_fields[id]) {
this._layer_state.extra_fields[id] = {};
}
this._layer_state.extra_fields[id][key] = value;
return this;
}
/**
* Select a filter function to be applied to the data. DEPRECATED: Please use the LocusZoom.MatchFunctions registry
* and reference via declarative filters.
* @param func
* @deprecated
*/
setFilter(func) {
console.warn('The setFilter method is deprecated and will be removed in the future; please use the layout API with a custom filter function instead');
this._filter_func = func;
}
/**
* A list of operations that should be run when the layout is mutated
* Typically, these are things done once when a layout is first specified, that would not automatically
* update when the layout was changed.
* @public
*/
mutateLayout() {
// Are we fetching data from external providers? If so, validate that those API calls would meet the expected contract.
if (this.parent_plot) { // Don't run this method if instance isn't mounted to a plot, eg unit tests that don't require requester
const { namespace, data_operations } = this.layout;
this._data_contract = findFields(this.layout, Object.keys(namespace));
const [entities, dependencies] = this.parent_plot.lzd.config_to_sources(namespace, data_operations, this);
this._entities = entities;
this._dependencies = dependencies;
}
}
/********** Protected methods: useful in subclasses to manipulate data layer behaviors */
/**
* Implementation hook for fetching the min and max values of available data. Used to determine axis range, if no other
* explicit axis settings override. Useful for data layers where the data extent depends on more than one field.
* (eg confidence intervals in a forest plot)
*
* @protected
* @param data
* @param axis_config The configuration object for the specified axis.
* @returns {Array} [min, max] without any padding applied
*/
_getDataExtent (data, axis_config) {
data = data || this.data;
// By default this depends only on a single field.
return d3.extent(data, (d) => {
const f = new Field(axis_config.field);
return +f.resolve(d);
});
}
/**
* Fetch the fully qualified ID to be associated with a specific visual element, based on the data to which that
* element is bound. In general this element ID will be unique, allowing it to be addressed directly via selectors.
*
* The ID should also be stable across re-renders, so that tooltips and highlights may be reapplied to that
* element as we switch regions or drag left/right. If the element is not unique along a single field (eg PheWAS data),
* a unique ID can be generated via a template expression like `{{phewas:pheno}}-{{phewas:trait_label}}`
* @protected
* @param {Object} element The data associated with a particular element
* @returns {String}
*/
getElementId (element) {
// Use a cached value if possible
const id_key = Symbol.for('lzID');
if (element[id_key]) {
return element[id_key];
}
// Two ways to get element ID: field can specify an exact field name, or, we can parse a template expression
const id_field = this.layout.id_field;
let value = element[id_field];
if (typeof value === 'undefined' && /{{[^{}]*}}/.test(id_field)) {
// No field value was found directly, but if it looks like a template expression, next, try parsing that
// WARNING: In this mode, it doesn't validate that all requested fields from the template are present. Only use this if you trust the data being given to the plot!
value = parseFields(id_field, element, {}); // Not allowed to use annotations b/c IDs should be stable, and annos may be transient
}
if (value === null || value === undefined) {
// Neither exact field nor template options produced an ID
throw new Error('Unable to generate element ID');
}
const element_id = value.toString().replace(/\W/g, '');
// Cache ID value for future calls
const key = (`${this.getBaseId()}-${element_id}`).replace(/([:.[\],])/g, '_');
element[id_key] = key;
return key;
}
/**
* Abstract method. It should be overridden by data layers that implement separate status
* nodes, such as genes or intervals.
* Fetch an ID that may bind a data element to a separate visual node for displaying status
* Examples of this might be highlighting a gene with a surrounding box to show select/highlight statuses, or
* a group of unrelated intervals (all markings grouped within a category).
* @private
* @param {String|Object} element
* @returns {String|null}
*/
getElementStatusNodeId (element) {
return null;
}
/**
* Returns a reference to the underlying data associated with a single visual element in the data layer, as
* referenced by the unique identifier for the element
*
* @ignore
* @protected
* @param {String} id The unique identifier for the element, as defined by `getElementId`
* @returns {Object|null} The data bound to that element
*/
getElementById(id) {
const selector = d3.select(`#${id.replace(/([:.[\],])/g, '\\$1')}`); // escape special characters
if (!selector.empty() && selector.data() && selector.data().length) {
return selector.data()[0];
} else {
return null;
}
}
/**
* Basic method to apply arbitrary methods and properties to data elements.
* This is called on all data immediately after being fetched. (requires reMap, not just re-render)
*
* Allowing a data element to access its parent enables interactive functionality, such as tooltips that modify
* the parent plot. This is also used for system-derived fields like "matching" behavior".
*
* @protected
* @returns {BaseDataLayer}
*/
applyDataMethods() {
const field_to_match = (this.layout.match && this.layout.match.receive);
const match_function = MATCHERS.get(this.layout.match && this.layout.match.operator || '=');
const broadcast_value = this.parent_plot.state.lz_match_value;
// Match functions are allowed to use transform syntax on field values, but not (yet) UI "annotations"
const field_resolver = field_to_match ? new Field(field_to_match) : null;
// Does the data from the API satisfy the list of fields expected by this layout?
// Not every record will have every possible field (example: left joins like assoc + ld). The check is "did
// we see this field at least once in any record at all".
if (this.data.length && this._data_contract.size) {
const fields_unseen = new Set(this._data_contract);
for (let record of this.data) {
Object.keys(record).forEach((field) => fields_unseen.delete(field));
if (!fields_unseen.size) {
// Once every requested field has been seen in at least one record, no need to look at more records
break;
}
}
if (fields_unseen.size) {
// Current implementation is a soft warning, so that certain "incremental enhancement" features
// (like rsIDs in tooltips) can fail gracefully if the API does not provide the requested info.
// Template syntax like `{{#if fieldname}}` means that throwing an Error is not always the right answer for missing data.
console.debug(`Data layer '${this.getBaseId()}' did not receive all expected fields from retrieved data. Missing fields are: ${[...fields_unseen]}
Common reasons for this error include API payloads with missing fields, or data layer layouts that use two kinds of data and need join logic in "data_operations"
If this field is optional, you can safely ignore this message. Examples include {{#if value}} tags or conditional color rules.
`);
}
}
this.data.forEach((item, i) => {
// Basic toHTML() method - return the stringified value in the id_field, if defined.
// When this layer receives data, mark whether points match (via a synthetic boolean field)
// Any field-based layout directives (color, size, shape) can then be used to control display
if (field_to_match && broadcast_value !== null && broadcast_value !== undefined) {
item.lz_is_match = match_function(field_resolver.resolve(item), broadcast_value);
}
// Helper methods - return a reference to various plot levels. Useful for interactive tooltips.
item.getDataLayer = () => this;
item.getPanel = () => this.parent || null;
item.getPlot = () => {
// For unit testing etc, this layer may be created without a parent.
const panel = this.parent;
return panel ? panel.parent : null;
};
});
this.applyCustomDataMethods();
return this;
}
/**
* Hook that allows custom datalayers to apply additional methods and properties to data elements as needed.
* Most data layers will never need to use this.
* @protected
* @returns {BaseDataLayer}
*/
applyCustomDataMethods() {
return this;
}
/**
* Apply scaling functions to an element as needed, based on the layout rules governing display + the element's data
* If the layout parameter is already a primitive type, simply return the value as given
*
* In the future this may be further expanded, so that scaling functions can operate similar to mappers
* (item, index, array). Additional arguments would be added as the need arose.
*
* @private
* @param {Array|Number|String|Object} option_layout Either a scalar ("color is red") or a configuration object
* ("rules for how to choose color based on item value")
* @param {*} element_data The value to be used with the filter. May be a primitive value, or a data object for a single item
* @param {Number} data_index The array index for the data element
* @returns {*} The transformed value
*/
resolveScalableParameter (option_layout, element_data, data_index) {
let ret = null;
if (Array.isArray(option_layout)) {
let idx = 0;
while (ret === null && idx < option_layout.length) {
ret = this.resolveScalableParameter(option_layout[idx], element_data, data_index);
idx++;
}
} else {
switch (typeof option_layout) {
case 'number':
case 'string':
ret = option_layout;
break;
case 'object':
if (option_layout.scale_function) {
const func = SCALABLE.get(option_layout.scale_function);
if (option_layout.field) {
const f = new Field(option_layout.field);
let extra;
try {
extra = this.getElementAnnotation(element_data);
} catch (e) {
extra = null;
}
ret = func(option_layout.parameters || {}, f.resolve(element_data, extra), data_index);
} else {
ret = func(option_layout.parameters || {}, element_data, data_index);
}
}
break;
}
}
return ret;
}
/**
* Generate dimension extent function based on layout parameters
* @ignore
* @protected
* @param {('x'|'y')} dimension
*/
getAxisExtent (dimension) {
if (!['x', 'y'].includes(dimension)) {
throw new Error('Invalid dimension identifier');
}
const axis_name = `${dimension}_axis`;
const axis_layout = this.layout[axis_name];
// If a floor AND a ceiling are explicitly defined then just return that extent and be done
if (!isNaN(axis_layout.floor) && !isNaN(axis_layout.ceiling)) {
return [+axis_layout.floor, +axis_layout.ceiling];
}
// If a field is defined for the axis and the data layer has data then generate the extent from the data set
let data_extent = [];
if (axis_layout.field && this.data) {
if (!this.data.length) {
// If data has been fetched (but no points in region), enforce the min_extent (with no buffers,
// because we don't need padding around an empty screen)
data_extent = axis_layout.min_extent || [];
return data_extent;
} else {
data_extent = this._getDataExtent(this.data, axis_layout);
// Apply upper/lower buffers, if applicable
const original_extent_span = data_extent[1] - data_extent[0];
if (!isNaN(axis_layout.lower_buffer)) {
data_extent[0] -= original_extent_span * axis_layout.lower_buffer;
}
if (!isNaN(axis_layout.upper_buffer)) {
data_extent[1] += original_extent_span * axis_layout.upper_buffer;
}
if (typeof axis_layout.min_extent == 'object') {
// The data should span at least the range specified by min_extent, an array with [low, high]
const range_min = axis_layout.min_extent[0];
const range_max = axis_layout.min_extent[1];
if (!isNaN(range_min) && !isNaN(range_max)) {
data_extent[0] = Math.min(data_extent[0], range_min);
}
if (!isNaN(range_max)) {
data_extent[1] = Math.max(data_extent[1], range_max);
}
}
// If specified, floor and ceiling will override the actual data range
return [
isNaN(axis_layout.floor) ? data_extent[0] : axis_layout.floor,
isNaN(axis_layout.ceiling) ? data_extent[1] : axis_layout.ceiling,
];
}
}
// If this is for the x axis and no extent could be generated yet but state has a defined start and end
// then default to using the state-defined region as the extent
if (dimension === 'x' && !isNaN(this.state.start) && !isNaN(this.state.end)) {
return [this.state.start, this.state.end];
}
// No conditions met for generating a valid extent, return an empty array
return [];
}
/**
* Allow this data layer to tell the panel what axis ticks it thinks it will require. The panel may choose whether
* to use some, all, or none of these when rendering, either alone or in conjunction with other data layers.
*
* This method is a stub and should be overridden in data layers that need to specify custom behavior.
*
* @protected
* @param {('x'|'y1'|'y2')} dimension
* @param {Object} [config] Additional parameters for the panel to specify how it wants ticks to be drawn. The names
* and meanings of these parameters may vary between different data layers.
* @returns {Object[]}
* An array of objects: each object must have an 'x' attribute to position the tick.
* Other supported object keys:
* * text: string to render for a given tick
* * style: d3-compatible CSS style object
* * transform: SVG transform attribute string
* * color: string or LocusZoom scalable parameter object
*/
getTicks (dimension, config) {
if (!['x', 'y1', 'y2'].includes(dimension)) {
throw new Error(`Invalid dimension identifier ${dimension}`);
}
return [];
}
/**
* Determine the coordinates for where to point the tooltip at. Typically, this is the center of a datum element (eg,
* the middle of a scatter plot point). Also provide an offset if the tooltip should not be at that center (most
* elements are not single points, eg a scatter plot point has a radius and a gene is a rectangle).
* The default implementation is quite naive: it places the tooltip at the origin for that layer. Individual layers
* should override this method to position relative to the chosen data element or mouse event.
* @protected
* @param {Object} tooltip A tooltip object (including attribute tooltip.data)
* @returns {Object} as {x_min, x_max, y_min, y_max} in px, representing bounding box of a rectangle around the data pt
* Note that these pixels are in the SVG coordinate system
*/
_getTooltipPosition(tooltip) {
const panel = this.parent;
const y_scale = panel[`y${this.layout.y_axis.axis}_scale`];
const y_extent = panel[`y${this.layout.y_axis.axis}_extent`];
const x = panel.x_scale(panel.x_extent[0]);
const y = y_scale(y_extent[0]);
return { x_min: x, x_max: x, y_min: y, y_max: y };
}
/**
* Draw a tooltip on the data layer pointed at the specified coordinates, in the specified orientation.
* Tooltip will be drawn on the edge of the major axis, and centered along the minor axis- see diagram.
* v
* > o <
* ^
*
* @protected
* @param tooltip {Object} The object representing all data for the tooltip to be drawn
* @param {'vertical'|'horizontal'|'top'|'bottom'|'left'|'right'} position Where to draw the tooltip relative to
* the data
* @param {Number} x_min The min x-coordinate for the bounding box of the data element
* @param {Number} x_max The max x-coordinate for the bounding box of the data element
* @param {Number} y_min The min y-coordinate for the bounding box of the data element
* @param {Number} y_max The max y-coordinate for the bounding box of the data element
*/
_drawTooltip(tooltip, position, x_min, x_max, y_min, y_max) {
const panel_layout = this.parent.layout;
const plot_layout = this.parent_plot.layout;
const layer_layout = this.layout;
// Tooltip position params: as defined in the default stylesheet, used in calculations
const arrow_size = 7;
const stroke_width = 1;
const arrow_total = arrow_size + stroke_width; // Tooltip pos should account for how much space the arrow takes up
const tooltip_padding = 6; // bbox size must account for any internal padding applied between data and border
const page_origin = this._getPageOrigin();
const tooltip_box = tooltip.selector.node().getBoundingClientRect();
const data_layer_height = panel_layout.height - (panel_layout.margin.top + panel_layout.margin.bottom);
const data_layer_width = plot_layout.width - (panel_layout.margin.left + panel_layout.margin.right);
// Clip the edges of the datum to the available plot area
x_min = Math.max(x_min, 0);
x_max = Math.min(x_max, data_layer_width);
y_min = Math.max(y_min, 0);
y_max = Math.min(y_max, data_layer_height);
const x_center = (x_min + x_max) / 2;
const y_center = (y_min + y_max) / 2;
// Default offsets are the far edge of the datum bounding box
let x_offset = x_max - x_center;
let y_offset = y_max - y_center;
let placement = layer_layout.tooltip_positioning;
// Coordinate system note: the tooltip is positioned relative to the plot/page; the arrow is positioned relative to
// the tooltip boundaries
let tooltip_top, tooltip_left, arrow_type, arrow_top, arrow_left;
// The user can specify a generic orientation, and LocusZoom will autoselect whether to place the tooltip above or below
if (placement === 'vertical') {
// Auto-select whether to position above the item, or below
x_offset = 0;
if (tooltip_box.height + arrow_total > data_layer_height - (y_center + y_offset)) {
placement = 'top';
} else {
placement = 'bottom';
}
} else if (placement === 'horizontal') {
// Auto select whether to position to the left of the item, or to the right
y_offset = 0;
if (x_center <= plot_layout.width / 2) {
placement = 'left';
} else {
placement = 'right';
}
}
if (placement === 'top' || placement === 'bottom') {
// Position horizontally centered above the point
const offset_right = Math.max((tooltip_box.width / 2) - x_center, 0);
const offset_left = Math.max((tooltip_box.width / 2) + x_center - data_layer_width, 0);
tooltip_left = page_origin.x + x_center - (tooltip_box.width / 2) - offset_left + offset_right;
arrow_left = page_origin.x + x_center - tooltip_left - arrow_size; // Arrow should be centered over the data
// Position vertically above the point unless there's insufficient space, then go below
if (placement === 'top') {
tooltip_top = page_origin.y + y_center - (y_offset + tooltip_box.height + arrow_total);
arrow_type = 'down';
arrow_top = tooltip_box.height - stroke_width;
} else {
tooltip_top = page_origin.y + y_center + y_offset + arrow_total;
arrow_type = 'up';
arrow_top = 0 - arrow_total;
}
} else if (placement === 'left' || placement === 'right') {
// Position tooltip horizontally on the left or the right depending on which side of the plot the point is on
if (placement === 'left') {
tooltip_left = page_origin.x + x_center + x_offset + arrow_total;
arrow_type = 'left';
arrow_left = -1 * (arrow_size + stroke_width);
} else {
tooltip_left = page_origin.x + x_center - tooltip_box.width - x_offset - arrow_total;
arrow_type = 'right';
arrow_left = tooltip_box.width - stroke_width;
}
// Position with arrow vertically centered along tooltip edge unless we're at the top or bottom of the plot
if (y_center - (tooltip_box.height / 2) <= 0) { // Too close to the top, push it down
tooltip_top = page_origin.y + y_center - (1.5 * arrow_size) - tooltip_padding;
arrow_top = tooltip_padding;
} else if (y_center + (tooltip_box.height / 2) >= data_layer_height) { // Too close to the bottom, pull it up
tooltip_top = page_origin.y + y_center + arrow_size + tooltip_padding - tooltip_box.height;
arrow_top = tooltip_box.height - (2 * arrow_size) - tooltip_padding;
} else { // vertically centered
tooltip_top = page_origin.y + y_center - (tooltip_box.height / 2);
arrow_top = (tooltip_box.height / 2) - arrow_size;
}
} else {
throw new Error('Unrecognized placement value');
}
// Position the div itself, relative to the layer origin
tooltip.selector
.style('left', `${tooltip_left}px`)
.style('top', `${tooltip_top}px`);
// Create / update position on arrow connecting tooltip to data
if (!tooltip.arrow) {
tooltip.arrow = tooltip.selector.append('div')
.style('position', 'absolute');
}
tooltip.arrow
.attr('class', `lz-data_layer-tooltip-arrow_${arrow_type}`)
.style('left', `${arrow_left}px`)
.style('top', `${arrow_top}px`);
return this;
}
/**
* Determine whether a given data element matches all predefined filter criteria, usually as specified in a layout directive.
*
* Typically this is used with array.filter (the first argument is curried, `this.filter.bind(this, options)`
* @private
* @param {Object[]} filter_rules A list of rule entries: {field, value, operator} describing each filter.
* Operator must be from a list of built-in operators. If the field is omitted, the entire datum object will be
* passed to the filter, rather than a single scalar value. (this is only useful with custom `MatchFunctions` as operator)
* @param {Object} item
* @param {Number} index
* @param {Array} array
* @returns {Boolean} Whether the specified item is a match
*/
filter(filter_rules, item, index, array) {
let is_match = true;
filter_rules.forEach((filter) => { // Try each filter on this item, in sequence
const {field, operator, value: target} = filter;
const test_func = MATCHERS.get(operator);
// Return the field value or annotation. If no `field` is specified, the filter function will operate on
// the entire data object. This behavior is only really useful with custom functions, because the
// builtin ones expect to receive a scalar value
const extra = this.getElementAnnotation(item);
const field_value = field ? (new Field(field)).resolve(item, extra) : item;
if (!test_func(field_value, target)) {
is_match = false;
}
});
return is_match;
}
/**
* Get "annotation" metadata associated with a particular point.
*
* @protected
* @param {String|Object} element The data object or ID string for the element
* @param {String} [key] The name of the annotation to track. If omitted, returns all annotations for this element as an object.
* @return {*}
*/
getElementAnnotation (element, key) {
const id = this.getElementId(element);
const extra = this._layer_state.extra_fields[id];
return key ? (extra && extra[key]) : extra;
}
/****** Private methods: rarely overridden or modified by external usages */
/**
* Apply filtering options to determine the set of data to render
*
* This must be applied on rendering, not fetch, so that the axis limits reflect the true range of the dataset
* Otherwise, two stacked panels (same dataset filtered in different ways) might not line up on the x-axis when
* filters are applied.
* @param data
* @return {*}
* @private
*/
_applyFilters(data) {
data = data || this.data;
if (this._filter_func) {
data = data.filter(this._filter_func);
} else if (this.layout.filters) {
data = data.filter(this.filter.bind(this, this.layout.filters));
}
return data;
}
/**
* Define default state that should get tracked during the lifetime of this layer.
*
* In some special custom usages, it may be useful to completely reset a panel (eg "click for
* genome region" links), plotting new data that invalidates any previously tracked state. This hook makes it
* possible to reset without destroying the panel entirely. It is used by `Plot.clearPanelData`.
* @private
*/
_setDefaultState() {
// Each datalayer tracks two kinds of status: flags for internal state (highlighted, selected, tooltip),
// and "extra fields" (annotations like "show a tooltip" that are not determined by the server, but need to
// persist across re-render)
const _layer_state = { status_flags: {}, extra_fields: {} };
const status_flags = _layer_state.status_flags;
STATUSES.adjectives.forEach((status) => {
status_flags[status] = status_flags[status] || new Set();
});
// Also initialize "internal-only" state fields (things that are tracked, but not set directly by external events)
status_flags['has_tooltip'] = status_flags['has_tooltip'] || new Set();
if (this.parent) {
// If layer has a parent, store a reference in the overarching plot.state object
this._state_id = `${this.parent.id}.${this.id}`;
this.state = this.parent.state;
this.state[this._state_id] = _layer_state;
}
this._layer_state = _layer_state;
}
/**
* Get the fully qualified identifier for the data layer, prefixed by any parent or container elements
*
* @private
* @returns {string} A dot-delimited string of the format <plot>.<panel>.<data_layer>
*/
getBaseId () {
if (this._base_id) {
return this._base_id;
}
if (this.parent) {
return `${this.parent_plot.id}.${this.parent.id}.${this.id}`;
} else {
return (this.id || '').toString();
}
}
/**
* Determine the pixel height of data-bound objects represented inside this data layer. (excluding elements such as axes)
*
* May be used by operations that resize the data layer to fit available data
*
* @private
* @returns {number}
*/
getAbsoluteDataHeight() {
const dataBCR = this.svg.group.node().getBoundingClientRect();
return dataBCR.height;
}
/**
* Initialize a data layer
* @private
* @returns {BaseDataLayer}
*/
initialize() {
this._base_id = this.getBaseId();
// Append a container group element to house the main data layer group element and the clip path
const base_id = this.getBaseId();
this.svg.container = this.parent.svg.group.append('g')
.attr('class', 'lz-data_layer-container')
.attr('id', `${base_id}.data_layer_container`);
// Append clip path to the container element
this.svg.clipRect = this.svg.container.append('clipPath')
.attr('id', `${base_id}.clip`)
.append('rect');
// Append svg group for rendering all data layer elements, clipped by the clip path
this.svg.group = this.svg.container.append('g')
.attr('id', `${base_id}.data_layer`)
.attr('clip-path', `url(#${base_id}.clip)`);
return this;
}
/**
* Generate a tool tip for a given element
* @private
* @param {String|Object} data Data for the element associated with the tooltip
*/
createTooltip (data) {
if (typeof this.layout.tooltip != 'object') {
throw new Error(`DataLayer [${this.id}] layout does not define a tooltip`);
}
const id = this.getElementId(data);
if (this._tooltips[id]) {
this.positionTooltip(id);
return;
}
this._tooltips[id] = {
data: data,
arrow: null,
selector: d3.select(this.parent_plot.svg.node().parentNode).append('div')
.attr('class', 'lz-data_layer-tooltip')
.attr('id', `${id}-tooltip`),
};
this._layer_state.status_flags['has_tooltip'].add(id);
this.updateTooltip(data);
return this;
}
/**
* Update a tool tip (generate its inner HTML)
*
* @private
* @param {String|Object} d The element associated with the tooltip
* @param {String} [id] An identifier to the tooltip
*/
updateTooltip(d, id) {
if (typeof id == 'undefined') {
id = this.getElementId(d);
}
// Empty the tooltip of all HTML (including its arrow!)
this._tooltips[id].selector.html('');
this._tooltips[id].arrow = null;
// Set the new HTML
if (this.layout.tooltip.html) {
this._tooltips[id].selector.html(parseFields(this.layout.tooltip.html, d, this.getElementAnnotation(d)));
}
// If the layout allows tool tips on this data layer to be closable then add the close button
// and add padding to the tooltip to accommodate it
if (this.layout.tooltip.closable) {
this._tooltips[id].selector.insert('button', ':first-child')
.attr('class', 'lz-tooltip-close-button')
.attr('title', 'Close')
.text('×')
.on('click', () => {
this.destroyTooltip(id);
});
}
// Apply data directly to the tool tip for easier retrieval by custom UI elements inside the tool tip
this._tooltips[id].selector.data([d]);
// Reposition and draw a new arrow
this.positionTooltip(id);
return this;
}
/**
* Destroy tool tip - remove the tool tip element from the DOM and delete the tool tip's record on the data layer
*
* @private
* @param {String|Object} element_or_id The element (or id) associated with the tooltip
* @param {boolean} [temporary=false] Whether this is temporary (not to be tracked in state). Differentiates
* "recreate tooltips on re-render" (which is temporary) from "user has closed this tooltip" (permanent)
* @returns {BaseDataLayer}
*/
destroyTooltip(element_or_id, temporary) {
let id;
if (typeof element_or_id == 'string') {
id = element_or_id;
} else {
id = this.getElementId(element_or_id);
}
if (this._tooltips[id]) {
if (typeof this._tooltips[id].selector == 'object') {
this._tooltips[id].selector.remove();
}
delete this._tooltips[id];
}
// When a tooltip is removed, also remove the reference from the state
if (!temporary) {
const tooltip_state = this._layer_state.status_flags['has_tooltip'];
tooltip_state.delete(id);
}
return this;
}
/**
* Loop through and destroy all tool tips on this data layer
*
* @private
* @returns {BaseDataLayer}
*/
destroyAllTooltips(temporary = true) {
for (let id in this._tooltips) {
this.destroyTooltip(id, temporary);
}
return this;
}
/**
* Position and then redraw tool tip - naïve function to place a tool tip in the data layer. By default, positions wrt
* the top-left corner of the data layer.
*
* Each layer type may have more specific logic. Consider overriding the provided hooks `_getTooltipPosition` or
* `_drawTooltip` as appropriate
*
* @private
* @param {String} id The identifier of the tooltip to position
* @returns {BaseDataLayer}
*/
positionTooltip(id) {
if (typeof id != 'string') {
throw new Error('Unable to position tooltip: id is not a string');
}
if (!this._tooltips[id]) {
throw new Error('Unable to position tooltip: id does not point to a valid tooltip');
}
const tooltip = this._tooltips[id];
const coords = this._getTooltipPosition(tooltip);
if (!coords) {
// Special cutout: normally, tooltips are positioned based on the datum element. Some, like lines/curves,
// work better if based on a mouse event. Since not every redraw contains a mouse event, we can just skip
// calculating position when no position information is available.
return null;
}
this._drawTooltip(tooltip, this.layout.tooltip_positioning, coords.x_min, coords.x_max, coords.y_min, coords.y_max);
}
/**
* Loop through and position all tool tips on this data layer
*
* @private
* @returns {BaseDataLayer}
*/
positionAllTooltips() {
for (let id in this._tooltips) {
this.positionTooltip(id);
}
return this;
}
/**
* Show or hide a tool tip by ID depending on directives in the layout and state values relative to the ID
*
* @private
* @param {String|Object} element The element associated with the tooltip
* @param {boolean} first_time Because panels can re-render, the rules for showing a tooltip
* depend on whether this is the first time a status change affecting display has been applied.
* @returns {BaseDataLayer}
*/
showOrHideTooltip(element, first_time) {
const tooltip_layout = this.layout.tooltip;
if (typeof tooltip_layout != 'object') {
return this;
}
const id = this.getElementId(element);
/**
* Apply rules and decide whether to show or hide the tooltip
* @param {Object} statuses All statuses that apply to an element
* @param {String[]|object} directive A layout directive object
* @param operator
* @returns {null|bool}
*/
const resolveStatus = (statuses, directive, operator) => {
let status = null;
if (typeof statuses != 'object' || statuses === null) {
return null;
}
if (Array.isArray(directive)) {
// This happens when the function is called on the inner part of the directive
operator = operator || 'and';
if (directive.length === 1) {
status = statuses[directive[0]];
} else {
status = directive.reduce((previousValue, currentValue) => {
if (operator === 'and') {
return statuses[previousValue] && statuses[currentValue];
} else if (operator === 'or') {
return statuses[previousValue] || statuses[currentValue];
}
return null;
});
}
} else if (typeof directive == 'object') {
let sub_status;
for (let sub_operator in directive) {
sub_status = resolveStatus(statuses, directive[sub_operator], sub_operator);
if (status === null) {
status = sub_status;
} else if (operator === 'and') {
status = status && sub_status;
} else if (operator === 'or') {
status = status || sub_status;
}
}
} else {
return false;
}
return status;
};
let show_directive = {};
if (typeof tooltip_layout.show == 'string') {
show_directive = { and: [ tooltip_layout.show ] };
} else if (typeof tooltip_layout.show == 'object') {
show_directive = tooltip_layout.show;
}
let hide_directive = {};
if (typeof tooltip_layout.hide == 'string') {
hide_directive = { and: [ tooltip_layout.hide ] };
} else if (typeof tooltip_layout.hide == 'object') {
hide_directive = tooltip_layout.hide;
}
// Find all the statuses that apply to just this single element
const _layer_state = this._layer_state;
var status_flags = {}; // {status_name: bool}
STATUSES.adjectives.forEach((status) => {
const antistatus = `un${status}`;
status_flags[status] = (_layer_state.status_flags[status].has(id));
status_flags[antistatus] = !status_flags[status];
});
// Decide whether to show/hide the tooltip based solely on the underlying element
const show_resolved = resolveStatus(status_flags, show_directive);
const hide_resolved = resolveStatus(status_flags, hide_directive);
// Most of the tooltip display logic depends on behavior layouts: was point (un)selected, (un)highlighted, etc.
// But sometimes, a point is selected, and the user then closes the tooltip. If the panel is re-rendered for
// some outside reason (like state change), we must track this in the create/destroy events as tooltip state.
const has_tooltip = (_layer_state.status_flags['has_tooltip'].has(id));
const tooltip_was_closed = first_time ? false : !has_tooltip;
if (show_resolved && !tooltip_was_closed && !hide_resolved) {
this.createTooltip(element);
} else {
this.destroyTooltip(element);
}
return this;
}
/**
* Toggle a status (e.g. highlighted, selected, identified) on an element
*
* @private
* @fires event:layout_changed
* @fires event:element_selection
* @fires event:match_requested
* @param {String} status The name of a recognized status to be added/removed on an appropriate element
* @param {String|Object} element The data bound to the element of interest
* @param {Boolean} active True to add the status (and associated CSS styles); false to remove it
* @param {Boolean} exclusive Whether to only allow a state for a single element at a time
* @returns {BaseDataLayer}
*/
setElementStatus(status, element, active, exclusive) {
if (status === 'has_tooltip') {
// This is a special adjective that exists solely to track tooltip state. It has no CSS and never gets set
// directly. It is invisible to the official enums.
return this;
}
if (typeof active == 'undefined') {
active = true;
}
// Get an ID for the element or return having changed nothing
let element_id;
try {
element_id = this.getElementId(element);
} catch (get_element_id_error) {
return this;
}
// Enforce exclusivity (force all elements to have the opposite of toggle first)
if (exclusive) {
this.setAllElementStatus(status, !active);
}
// Set/unset the proper status class on the appropriate DOM element(s), *and* potentially an additional element
d3.select(`#${element_id}`).classed(`lz-data_layer-${this.layout.type}-${status}`, active);
const element_status_node_id = this.getElementStatusNodeId(element);
if (element_status_node_id !== null) {
d3.select(`#${element_status_node_id}`).classed(`lz-data_layer-${this.layout.type}-statusnode-${status}`, active);
}
// Track element ID in the proper status state array
const added_status = !this._layer_state.status_flags[status].has(element_id); // On a re-render, existing statuses will be reapplied.
if (active && added_status) {
this._layer_state.status_flags[status].add(element_id);
}
if (!active && !added_status) {
this._layer_state.status_flags[status].delete(element_id);
}
// Trigger tool tip show/hide logic
this.showOrHideTooltip(element, added_status);
// Trigger layout changed event hook
if (added_status) {
this.parent.emit('layout_changed', true);
}
const is_selected = (status === 'selected');
if (is_selected && (added_status || !active)) {
// Notify parents that an element has changed selection status (either active, or inactive)
this.parent.emit('element_selection', { element: element, active: active }, true);
}
const value_to_broadcast = (this.layout.match && this.layout.match.send);
if (is_selected && (typeof value_to_broadcast !== 'undefined') && (added_status || !active)) {
this.parent.emit(
// The broadcast value can use transforms to "clean up value before sending broadcasting"
'match_requested',
{ value: new Field(value_to_broadcast).resolve(element), active: active },
true,
);
}
return this;
}
/**
* Toggle a status on all elements in the data layer
*
* @private
* @param {String} status
* @param {Boolean} toggle
* @returns {BaseDataLayer}
*/
setAllElementStatus(status, toggle) {
// Sanity check
if (typeof status == 'undefined' || !STATUSES.adjectives.includes(status)) {
throw new Error('Invalid status');
}
if (typeof this._layer_state.status_flags[status] == 'undefined') {
return this;
}
if (typeof toggle == 'undefined') {
toggle = true;
}
// Apply statuses
if (toggle) {
this.data.forEach((element) => this.setElementStatus(status, element, true));
} else {
const status_ids = new Set(this._layer_state.status_flags[status]); // copy so that we don't mutate while iterating
status_ids.forEach((id) => {
const element = this.getElementById(id);
if (typeof element == 'object' && element !== null) {
this.setElementStatus(status, element, false);
}
});
this._layer_state.status_flags[status] = new Set();
}
// Update global status flag
this._global_statuses[status] = toggle;
return this;
}
/**
* Apply all layout-defined behaviors (DOM event handlers) to a selection of elements
*
* @private
* @param {d3.selection} selection
*/
applyBehaviors(selection) {
if (typeof this.layout.behaviors != 'object') {
return;
}
Object.keys(this.layout.behaviors).forEach((directive) => {
const event_match = /(click|mouseover|mouseout)/.exec(directive);
if (!event_match) {
return;
}
selection.on(`${event_match[0]}.${directive}`, this.executeBehaviors(directive, this.layout.behaviors[directive]));
});
}
/**
* Generate a function that executes an arbitrary list of behaviors on an element during an event
*
* @private
* @param {String} directive The name of the event, as described in layout.behaviors for this datalayer
* @param {Object[]} behaviors An object describing the behavior to attach to this single element
* @param {string} behaviors.action The name of the action that would trigger this behavior (eg click, mouseover, etc)
* @param {string} behaviors.status What status to apply to the element when this behavior is triggered (highlighted,
* selected, etc)
* @param {boolean} [behaviors.exclusive] Whether triggering the event for this element should unset the relevant status
* for all other elements. Useful for, eg, click events that exclusively highlight one thing.
* @returns {function(this:BaseDataLayer)} Return a function that handles the event in context with the behavior
* and the element- can be attached as an event listener
*/
executeBehaviors(directive, behaviors) {
// Determine the required state of control and shift keys during the event
const requiredKeyStates = {
'ctrl': (directive.includes('ctrl')),
'shift': (directive.includes('shift')),
};
const self = this;
return function(element) {
// This method may be used on two kinds of events: directly attached, or bubbled.
// D3 doesn't natively support bubbling very well; if no data is bound on the currentTarget, check to see
// if there is data available at wherever the event was initiated from
element = element || d3.select(d3.event.target).datum();
// Do nothing if the required control and shift key presses (or lack thereof) doesn't match the event
if (requiredKeyStates.ctrl !== !!d3.event.ctrlKey || requiredKeyStates.shift !== !!d3.event.shiftKey) {
return;
}
// Loop through behaviors making each one go in succession
behaviors.forEach((behavior) => {
// Route first by the action, if defined
if (typeof behavior != 'object' || behavior === null) {
return;
}
switch (behavior.action) {
// Set a status (set to true regardless of current status, optionally with exclusivity)
case 'set':
self.setElementStatus(behavior.status, element, true, behavior.exclusive);
break;
// Unset a status (set to false regardless of current status, optionally with exclusivity)
case 'unset':
self.setElementStatus(behavior.status, element, false, behavior.exclusive);
break;
// Toggle a status
case 'toggle':
var current_status_boolean = (self._layer_state.status_flags[behavior.status].has(self.getElementId(element)));
var exclusive = behavior.exclusive && !current_status_boolean;
self.setElementStatus(behavior.status, element, !current_status_boolean, exclusive);
break;
// Link to a dynamic URL
case 'link':
if (typeof behavior.href == 'string') {
const url = parseFields(behavior.href, element, self.getElementAnnotation(element));
if (typeof behavior.target == 'string') {
window.open(url, behavior.target);
} else {
window.location.href = url;
}
}
break;
// Action not defined, just return
default:
break;
}
});
};
}
/**
* Get an object with the x and y coordinates of the panel's origin in terms of the entire page
* Necessary for positioning any HTML elements over the panel
*
* @private
* @returns {{x: Number, y: Number}}
*/
_getPageOrigin() {
const panel_origin = this.parent._getPageOrigin();
return {
x: panel_origin.x + this.parent.layout.margin.left,
y: panel_origin.y + this.parent.layout.margin.top,
};
}
/**
* Apply all tracked element statuses. This is primarily intended for re-rendering the plot, in order to preserve
* behaviors when items are updated.
* @private
*/
applyAllElementStatus () {
const status_flags = this._layer_state.status_flags;
const self = this;
for (let property in status_flags) {
if (!Object.prototype.hasOwnProperty.call(status_flags, property)) {
continue;
}
status_flags[property].forEach((element_id) => {
try {
this.setElementStatus(property, this.getElementById(element_id), true);
} catch (e) {
console.warn(`Unable to apply state: ${self._state_id}, ${property}`);
console.error(e);
}
});
}
}
/**
* Position the datalayer and all tooltips
* @private
* @returns {BaseDataLayer}
*/
draw() {
this.svg.container
.attr('transform', `translate(${this.parent.layout.cliparea.origin.x}, ${this.parent.layout.cliparea.origin.y})`);
this.svg.clipRect
.attr('width', this.parent.layout.cliparea.width)
.attr('height', this.parent.layout.cliparea.height);
this.positionAllTooltips();
return this;
}
/**
* Re-Map a data layer to reflect changes in the state of a plot (such as viewing region/ chromosome range)
*
* Whereas .render draws whatever data is available, this method resets the view and fetches new data if necessary.
*
* @private
* @return {Promise}
*/
reMap() {
this.destroyAllTooltips(); // hack - only non-visible tooltips should be destroyed
// and then recreated if returning to visibility
// Fetch new data. Datalayers are only given access to the final consolidated data from the chain (not headers or raw payloads)
return this.parent_plot.lzd.getData(this.state, this._entities, this._dependencies)
.then((new_data) => {
this.data = new_data;
this.applyDataMethods();
this._initialized = true;
// Allow listeners (like subscribeToData) to see the information associated with a layer
this.parent.emit(
'data_from_layer',
{ layer: this.getBaseId(), content: deepCopy(new_data) }, // TODO: revamp event signature post layer-eventing-mixin
true,
);
});
}
}
STATUSES.verbs.forEach((verb, idx) => {
const adjective = STATUSES.adjectives[idx];
const antiverb = `un${verb}`;
// Set/unset a single element's status
/**
* @private
* @function highlightElement
*/
/**
* @private
* @function selectElement
*/
/**
* @private
* @function fadeElement
*/
/**
* @private
* @function hideElement
*/
BaseDataLayer.prototype[`${verb}Element`] = function(element, exclusive = false) {
exclusive = !!exclusive;
this.setElementStatus(adjective, element, true, exclusive);
return this;
};
/**
* @private
* @function unhighlightElement
*/
/**
* @private
* @function unselectElement
*/
/**
* @private
* @function unfadeElement
*/
/**
* @private
* @function unhideElement
*/
BaseDataLayer.prototype[`${antiverb}Element`] = function(element, exclusive) {
if (typeof exclusive == 'undefined') {
exclusive = false;
} else {
exclusive = !!exclusive;
}
this.setElementStatus(adjective, element, false, exclusive);
return this;
};
/**
* @private
* @function highlightAllElements
*/
/**
* @private
* @function selectAllElements
*/
/**
* @private
* @function fadeAllElements
*/
/**
* @private
* @function hideAllElements
*/
// Set/unset status for all elements
BaseDataLayer.prototype[`${verb}AllElements`] = function() {
this.setAllElementStatus(adjective, true);
return this;
};
/**
* @private
* @function unhighlightAllElements
*/
/**
* @private
* @function unselectAllElements
*/
/**
* @private
* @function unfadeAllElements
* */
/**
* @private
* @function unhideAllElements
*/
BaseDataLayer.prototype[`${antiverb}AllElements`] = function() {
this.setAllElementStatus(adjective, false);
return this;
};
});
export {BaseDataLayer as default};