/**
* Interval annotation track (for chromatin state, etc). Useful for BED file data with non-overlapping intervals.
* This is not part of the core LocusZoom library, but can be included as a standalone file.
*
* ### Features provided
* * {@link module:LocusZoom_Adapters~IntervalLZ}
* * {@link module:LocusZoom_Widgets~toggle_split_tracks}
* * {@link module:LocusZoom_ScaleFunctions~to_rgb}
* * {@link module:LocusZoom_DataLayers~intervals}
* * {@link module:LocusZoom_Layouts~standard_intervals}
* * {@link module:LocusZoom_Layouts~bed_intervals_layer}
* * {@link module:LocusZoom_Layouts~intervals_layer}
* * {@link module:LocusZoom_Layouts~intervals}
* * {@link module:LocusZoom_Layouts~bed_intervals}
* * {@link module:LocusZoom_Layouts~interval_association}
*
* ### 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-intervals-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 IntervalsTrack from 'locuszoom/esm/ext/lz-intervals-track';
* LocusZoom.use(IntervalsTrack);
* ```
*
* Then use the features made available by this extension. (see demos and documentation for guidance)
* @module
*/
import * as d3 from 'd3';
// Coordinates (start, end) are cached to facilitate rendering
const XCS = Symbol.for('lzXCS');
const YCS = Symbol.for('lzYCS');
const XCE = Symbol.for('lzXCE');
const YCE = Symbol.for('lzYCE');
function install (LocusZoom) {
const BaseUMAdapter = LocusZoom.Adapters.get('BaseUMAdapter');
const _Button = LocusZoom.Widgets.get('_Button');
const _BaseWidget = LocusZoom.Widgets.get('BaseWidget');
/**
* (**extension**) Retrieve Interval Annotation Data (e.g. BED Tracks), as fetched from the LocusZoom API server (or compatible)
* @public
* @alias module:LocusZoom_Adapters~IntervalLZ
* @see module:LocusZoom_Adapters~BaseUMAdapter
* @see {@link module:ext/lz-intervals-track} for required extension and installation instructions
* @param {number} config.params.source The numeric ID for a specific dataset as assigned by the API server
*/
class IntervalLZ extends BaseUMAdapter {
_getURL(request_options) {
const source = this._config.source;
const query = `?filter=id in ${source} and chromosome eq '${request_options.chr}' and start le ${request_options.end} and end ge ${request_options.start}`;
const base = super._getURL(request_options);
return `${base}${query}`;
}
}
/**
* (**extension**) Button to toggle split tracks mode in an intervals track. This button only works as a panel-level toolbar
* and when used with an intervals data layer from this extension.
* @alias module:LocusZoom_Widgets~toggle_split_tracks
* @see module:LocusZoom_Widgets~BaseWidget
* @see {@link module:ext/lz-intervals-track} for required extension and installation instructions
*/
class ToggleSplitTracks extends _BaseWidget {
/**
* @param {string} layout.data_layer_id The ID of the data layer that this button is intended to control.
*/
constructor(layout) {
super(...arguments);
if (!layout.data_layer_id) {
layout.data_layer_id = 'intervals';
}
if (!this.parent_panel.data_layers[layout.data_layer_id]) {
throw new Error('Toggle split tracks widget specifies an invalid data layer ID');
}
}
update() {
const data_layer = this.parent_panel.data_layers[this.layout.data_layer_id];
const html = data_layer.layout.split_tracks ? 'Merge Tracks' : 'Split Tracks';
if (this.button) {
this.button.setHtml(html);
this.button.show();
this.parent.position();
return this;
} else {
this.button = new _Button(this)
.setColor(this.layout.color)
.setHtml(html)
.setTitle('Toggle whether tracks are split apart or merged together')
.setOnclick(() => {
data_layer.toggleSplitTracks();
// FIXME: the timeout calls to scale and position (below) cause full ~5 additional re-renders
// If we can remove these it will greatly speed up re-rendering.
// The key problem here is that the height is apparently not known in advance and is determined after re-render.
if (this.scale_timeout) {
clearTimeout(this.scale_timeout);
}
this.scale_timeout = setTimeout(() => {
this.parent_panel.scaleHeightToData();
this.parent_plot.positionPanels();
}, 0);
this.update();
});
return this.update();
}
}
}
/**
* (**extension**) Convert a value ""rr,gg,bb" (if given) to a css-friendly color string: "rgb(rr,gg,bb)".
* This is tailored specifically to the color specification format embraced by the BED file standard.
* @alias module:LocusZoom_ScaleFunctions~to_rgb
* @param {Object} parameters This function has no defined configuration options
* @param {String|null} value The value to convert to rgb
* @see {@link module:ext/lz-intervals-track} for required extension and installation instructions
*/
function to_rgb(parameters, value) {
return value ? `rgb(${value})` : null;
}
const default_layout = {
start_field: 'start',
end_field: 'end',
track_label_field: 'state_name', // Used to label items on the y-axis
// Used to uniquely identify tracks for coloring. This tends to lead to more stable coloring/sorting
// than using the label field- eg, state_ids allow us to set global colors across the entire dataset,
// not just choose unique colors within a particular narrow region. (where changing region might lead to more
// categories and different colors)
track_split_field: 'state_id',
track_split_order: 'DESC',
track_split_legend_to_y_axis: 2,
split_tracks: true,
track_height: 15,
track_vertical_spacing: 3,
bounding_box_padding: 2,
always_hide_legend: false,
color: '#B8B8B8',
fill_opacity: 1,
tooltip_positioning: 'vertical',
};
const BaseLayer = LocusZoom.DataLayers.get('BaseDataLayer');
/**
* (**extension**) Implements a data layer that will render interval annotation tracks (intervals must provide start and end values)
* Each interval (such as from a BED file) will be rendered as a rectangle. All spans can be rendered on the same
* row, or each (auto-detected) category can be rendered as one row per category.
*
* This layer is intended to work with a variety of datasets with special requirements. As such, it has a lot
* of configuration options devoted to identifying how to fill in missing information (such as color)
*
* @alias module:LocusZoom_DataLayers~intervals
* @see module:LocusZoom_DataLayers~BaseDataLayer
* @see {@link module:ext/lz-intervals-track} for required extension and installation instructions
*/
class LzIntervalsTrack extends BaseLayer {
/**
* @param {string} [layout.start_field='start'] The field that defines interval start position
* @param {string} [layout.end_field='end'] The field that defines interval end position
* @param {string} [layout.track_label_field='state_name'] Used to label items on the y-axis
* @param {string} [layout.track_split_field='state_id'] Used to define categories on the y-axis. It is usually most convenient to use
* the same value for state_field and label_field (eg 1:1 correspondence).
* @param {*|'DESC'} [layout.track_split_order='DESC'] When in split tracks mode, should categories be shown in
* the order given, or descending order
* @param {number} [layout.track_split_legend_to_y_axis=2]
* @param {boolean} [layout.split_tracks=true] Whether to show tracks as merged (one row) or split (many rows)
* on initial render.
* @param {number} [layout.track_height=15] The height of each interval rectangle, in px
* @param {number} [layout.track_vertical_spacing=3]
* @param {number} [layout.bounding_box_padding=2]
* @param {boolean} [layout.always_hide_legend=false] Normally the legend is shown in merged mode and hidden
* in split mode. For datasets with a very large number of categories, it may make sense to hide the legend at all times.
* @param {string|module:LocusZoom_DataLayers~ScalableParameter[]} [layout.color='#B8B8B8'] The color of each datum rectangle
* @param {number|module:LocusZoom_DataLayers~ScalableParameter[]} [layout.fill_opacity=1]
* @param {string} [layout.tooltip_positioning='vertical']
*/
constructor(layout) {
LocusZoom.Layouts.merge(layout, default_layout);
super(...arguments);
this._previous_categories = [];
this._categories = [];
}
initialize() {
super.initialize();
this._statusnodes_group = this.svg.group.append('g')
.attr('class', 'lz-data-layer-intervals lz-data-layer-intervals-statusnode');
this._datanodes_group = this.svg.group.append('g')
.attr('class', 'lz-data_layer-intervals');
}
/**
* Split data into tracks such that anything with a common grouping field is in the same track
* @param data
* @return {unknown[]}
* @private
*/
_arrangeTrackSplit(data) {
const {track_split_field} = this.layout;
const result = {};
data.forEach((item) => {
const item_key = item[track_split_field];
if (!Object.prototype.hasOwnProperty.call(result, item_key)) {
result[item_key] = [];
}
result[item_key].push(item);
});
return result;
}
/**
* Split data into rows using a simple greedy algorithm such that no two items overlap (share same interval)
* Assumes that the data are sorted so item1.start always <= item2.start.
*
* This function can also simply return all data on a single row. This functionality may become configurable
* in the future but for now reflects a lack of clarity in the requirements/spec. The code to split
* overlapping items is present but may not see direct use.
*/
_arrangeTracksLinear(data, allow_overlap = true) {
if (allow_overlap) {
// If overlap is allowed, then all the data can live on a single row
return [data];
}
// ASSUMPTION: Data is given to us already sorted by start position to facilitate grouping.
// We do not sort here because JS "sort" is not stable- if there are many intervals that overlap, then we
// can get different layouts (number/order of rows) on each call to "render".
//
// At present, we decide how to update the y-axis based on whether current and former number of rows are
// the same. An unstable sort leads to layout thrashing/too many re-renders. FIXME: don't rely on counts
const {start_field, end_field} = this.layout;
const grouped_data = [[]]; // Prevent two items from colliding by rendering them to different rows, like genes
data.forEach((item, index) => {
for (let i = 0; i < grouped_data.length; i++) {
// Iterate over all rows of the
const row_to_test = grouped_data[i];
const last_item = row_to_test[row_to_test.length - 1];
// Some programs report open intervals, eg 0-1,1-2,2-3; these points are not considered to overlap (hence the test isn't "<=")
const has_overlap = last_item && (item[start_field] < last_item[end_field]) && (last_item[start_field] < item[end_field]);
if (!has_overlap) {
// If there is no overlap, add item to current row, and move on to the next item
row_to_test.push(item);
return;
}
}
// If this item would collide on all existing rows, create a new row
grouped_data.push([item]);
});
return grouped_data;
}
/**
* Annotate each item with the track number, and return.
* @param {Object[]}data
* @private
* @return [String[], Object[]] Return the categories and the data array
*/
_assignTracks(data) {
// Flatten the grouped data.
const {x_scale} = this.parent;
const {start_field, end_field, bounding_box_padding, track_height} = this.layout;
const grouped_data = this.layout.split_tracks ? this._arrangeTrackSplit(data) : this._arrangeTracksLinear(data, true);
const categories = Object.keys(grouped_data);
if (this.layout.track_split_order === 'DESC') {
categories.reverse();
}
categories.forEach((key, row_index) => {
const row = grouped_data[key];
row.forEach((item) => {
item[XCS] = x_scale(item[start_field]);
item[XCE] = x_scale(item[end_field]);
item[YCS] = row_index * this.getTrackHeight() + bounding_box_padding;
item[YCE] = item[YCS] + track_height;
// Store the row ID, so that clicking on a point can find the right status node (big highlight box)
item.track = row_index;
});
});
// We're mutating elements of the original data array as a side effect: the return value here is
// interchangeable with `this.data` for subsequent usages
// TODO: Can replace this with array.flat once polyfill support improves
return [categories, Object.values(grouped_data).reduce((acc, val) => acc.concat(val), [])];
}
/**
* When we are in "split tracks mode", it's convenient to wrap all individual annotations with a shared
* highlight box that wraps everything on that row.
*
* This is done automatically by the "setElementStatus" code, if this function returns a non-null value
*
* To define shared highlighting on the track split field define the status node id override
* to generate an ID common to the track when we're actively splitting data out to separate tracks
* @override
* @returns {String}
*/
getElementStatusNodeId(element) {
if (this.layout.split_tracks) {
// Data nodes are bound to data objects, but the "status_nodes" selection is bound to numeric row IDs
const track = typeof element === 'object' ? element.track : element;
const base = `${this.getBaseId()}-statusnode-${track}`;
return base.replace(/[^\w]/g, '_');
}
// In merged tracks mode, there is no separate status node
return null;
}
// Helper function to sum layout values to derive total height for a single interval track
getTrackHeight() {
return this.layout.track_height
+ this.layout.track_vertical_spacing
+ (2 * this.layout.bounding_box_padding);
}
// Modify the layout as necessary to ensure that appropriate color, label, and legend options are available
// Even when not displayed, the legend is used to generate the y-axis ticks
_applyLayoutOptions() {
const self = this;
const base_layout = this._base_layout;
const render_layout = this.layout;
const base_color_scale = base_layout.color.find(function (item) {
return item.scale_function && item.scale_function === 'categorical_bin';
});
const color_scale = render_layout.color.find(function (item) {
return item.scale_function && item.scale_function === 'categorical_bin';
});
if (!base_color_scale) {
// This can be a placeholder (empty categories & values), but it needs to be there
throw new Error('Interval tracks must define a `categorical_bin` color scale');
}
const has_colors = base_color_scale.parameters.categories.length && base_color_scale.parameters.values.length;
const has_legend = base_layout.legend && base_layout.legend.length;
if (!!has_colors ^ !!has_legend) {
// Don't allow color OR legend to be set manually. It must be both, or neither.
throw new Error('To use a manually specified color scheme, both color and legend options must be set.');
}
// Harvest any information about an explicit color field that should be considered when generating colors
const rgb_option = base_layout.color.find(function (item) {
return item.scale_function && item.scale_function === 'to_rgb';
});
const rgb_field = rgb_option && rgb_option.field;
// Auto-generate legend based on data
const known_categories = this._generateCategoriesFromData(this.data, rgb_field); // [id, label, itemRgb] items
if (!has_colors && !has_legend) {
// If no color scheme pre-defined, then make a color scheme that is appropriate and apply to the plot
// The legend must match the color scheme. If we generate one, then we must generate both.
const colors = this._makeColorScheme(known_categories);
color_scale.parameters.categories = known_categories.map(function (item) {
return item[0];
});
color_scale.parameters.values = colors;
this.layout.legend = known_categories.map(function (pair, index) {
const id = pair[0];
const label = pair[1];
const item_color = color_scale.parameters.values[index];
const item = { shape: 'rect', width: 9, label: label, color: item_color };
item[self.layout.track_split_field] = id;
return item;
});
}
}
// Implement the main render function
render() {
//// Autogenerate layout options if not provided
this._applyLayoutOptions();
// Determine the appropriate layout for tracks. Store the previous categories (y axis ticks) to decide
// whether the axis needs to be re-rendered.
this._previous_categories = this._categories;
const [categories, assigned_data] = this._assignTracks(this.data);
this._categories = categories;
// Update the legend axis if the number of ticks changed
const labels_changed = !categories.every( (item, index) => item === this._previous_categories[index]);
if (labels_changed) {
this.updateSplitTrackAxis(categories);
return;
}
// Apply filters to only render a specified set of points. Hidden fields will still be given space to render, but not shown.
const track_data = this._applyFilters(assigned_data);
// Clear before every render so that, eg, highlighting doesn't persist if we load a region with different
// categories (row 2 might be a different category and it's confusing if the row stays highlighted but changes meaning)
// Highlighting will automatically get added back if it actually makes sense, courtesy of setElementStatus,
// if a selected item is still in view after the new region loads.
this._statusnodes_group.selectAll('rect')
.remove();
// Reselect in order to add new data
const status_nodes = this._statusnodes_group.selectAll('rect')
.data(d3.range(categories.length));
if (this.layout.split_tracks) {
// Status nodes: a big highlight box around all items of the same type. Used in split tracks mode,
// because everything on the same row is the same category and a group makes sense
// There are no status nodes in merged mode, because the same row contains many kinds of things
// Status nodes are 1 per row, so "data" can just be a dummy list of possible row IDs
// Each status node is a box that runs the length of the panel and receives a special "colored box" css
// style when selected
const height = this.getTrackHeight();
status_nodes.enter()
.append('rect')
.attr('class', 'lz-data_layer-intervals lz-data_layer-intervals-statusnode lz-data_layer-intervals-shared')
.attr('rx', this.layout.bounding_box_padding)
.attr('ry', this.layout.bounding_box_padding)
.merge(status_nodes)
.attr('id', (d) => this.getElementStatusNodeId(d))
.attr('x', 0)
.attr('y', (d) => (d * height))
.attr('width', this.parent.layout.cliparea.width)
.attr('height', Math.max(height - this.layout.track_vertical_spacing, 1));
}
status_nodes.exit()
.remove();
// Draw rectangles for the data (intervals)
const data_nodes = this._datanodes_group.selectAll('rect')
.data(track_data, (d) => d[this.layout.id_field]);
data_nodes.enter()
.append('rect')
.merge(data_nodes)
.attr('id', (d) => this.getElementId(d))
.attr('x', (d) => d[XCS])
.attr('y', (d) => d[YCS])
.attr('width', (d) => Math.max(d[XCE] - d[XCS], 1))
.attr('height', this.layout.track_height)
.attr('fill', (d, i) => this.resolveScalableParameter(this.layout.color, d, i))
.attr('fill-opacity', (d, i) => this.resolveScalableParameter(this.layout.fill_opacity, d, i));
data_nodes.exit()
.remove();
this._datanodes_group
.call(this.applyBehaviors.bind(this));
// The intervals track allows legends to be dynamically generated, in which case space can only be
// allocated after the panel has been rendered.
if (this.parent && this.parent.legend) {
this.parent.legend.render();
}
}
_getTooltipPosition(tooltip) {
return {
x_min: tooltip.data[XCS],
x_max: tooltip.data[XCE],
y_min: tooltip.data[YCS],
y_max: tooltip.data[YCE],
};
}
// Redraw split track axis or hide it, and show/hide the legend, as determined
// by current layout parameters and data
updateSplitTrackAxis(categories) {
const legend_axis = this.layout.track_split_legend_to_y_axis ? `y${this.layout.track_split_legend_to_y_axis}` : false;
if (this.layout.split_tracks) {
const tracks = +categories.length || 0;
const track_height = +this.layout.track_height || 0;
const track_spacing = 2 * (+this.layout.bounding_box_padding || 0) + (+this.layout.track_vertical_spacing || 0);
const target_height = (tracks * track_height) + ((tracks - 1) * track_spacing);
this.parent.scaleHeightToData(target_height);
if (legend_axis && this.parent.legend) {
this.parent.legend.hide();
this.parent.layout.axes[legend_axis] = {
render: true,
ticks: [],
range: {
start: (target_height - (this.layout.track_height / 2)),
end: (this.layout.track_height / 2),
},
};
// There is a very tight coupling between the display directives: each legend item must identify a key
// field for unique tracks. (Typically this is `state_id`, the same key field used to assign unique colors)
// The list of unique keys corresponds to the order along the y-axis
this.layout.legend.forEach((element) => {
const key = element[this.layout.track_split_field];
let track = categories.findIndex((item) => item === key);
if (track !== -1) {
if (this.layout.track_split_order === 'DESC') {
track = Math.abs(track - tracks - 1);
}
this.parent.layout.axes[legend_axis].ticks.push({
y: track - 1,
text: element.label,
});
}
});
this.layout.y_axis = {
axis: this.layout.track_split_legend_to_y_axis,
floor: 1,
ceiling: tracks,
};
}
// This will trigger a re-render
this.parent_plot.positionPanels();
} else {
if (legend_axis && this.parent.legend) {
if (!this.layout.always_hide_legend) {
this.parent.legend.show();
}
this.parent.layout.axes[legend_axis] = { render: false };
this.parent.render();
}
}
return this;
}
// Method to not only toggle the split tracks boolean but also update
// necessary display values to animate a complete merge/split
toggleSplitTracks() {
this.layout.split_tracks = !this.layout.split_tracks;
if (this.parent.legend && !this.layout.always_hide_legend) {
this.parent.layout.margin.bottom = 5 + (this.layout.split_tracks ? 0 : this.parent.legend.layout.height + 5);
}
this.render();
return this;
}
// Choose an appropriate color scheme based on the number of items in the track, and whether or not we are
// using explicitly provided itemRgb information
_makeColorScheme(category_info) {
// If at least one element has an explicit itemRgb, assume the entire dataset has colors. BED intervals require rgb triplets,so assume that colors will always be "r,g,b" format.
const has_explicit_colors = category_info.find((item) => item[2]);
if (has_explicit_colors) {
return category_info.map((item) => to_rgb({}, item[2]));
}
// Use a set of color schemes for common 15, 18, or 25 state models, as specified from:
// https://egg2.wustl.edu/roadmap/web_portal/chr_state_learning.html
// These are actually reversed so that dim colors come first, on the premise that usually these are the
// most common states
const n_categories = category_info.length;
if (n_categories <= 15) {
return ['rgb(212,212,212)', 'rgb(192,192,192)', 'rgb(128,128,128)', 'rgb(189,183,107)', 'rgb(233,150,122)', 'rgb(205,92,92)', 'rgb(138,145,208)', 'rgb(102,205,170)', 'rgb(255,255,0)', 'rgb(194,225,5)', 'rgb(0,100,0)', 'rgb(0,128,0)', 'rgb(50,205,50)', 'rgb(255,69,0)', 'rgb(255,0,0)'];
} else if (n_categories <= 18) {
return ['rgb(212,212,212)', 'rgb(192,192,192)', 'rgb(128,128,128)', 'rgb(189,183,107)', 'rgb(205,92,92)', 'rgb(138,145,208)', 'rgb(102,205,170)', 'rgb(255,255,0)', 'rgb(255,195,77)', 'rgb(255,195,77)', 'rgb(194,225,5)', 'rgb(194,225,5)', 'rgb(0,100,0)', 'rgb(0,128,0)', 'rgb(255,69,0)', 'rgb(255,69,0)', 'rgb(255,69,0)', 'rgb(255,0,0)'];
} else {
// If there are more than 25 categories, the interval layer will fall back to the 'null value' option
return ['rgb(212,212,212)', 'rgb(128,128,128)', 'rgb(112,48,160)', 'rgb(230,184,183)', 'rgb(138,145,208)', 'rgb(102,205,170)', 'rgb(255,255,102)', 'rgb(255,255,0)', 'rgb(255,255,0)', 'rgb(255,255,0)', 'rgb(255,195,77)', 'rgb(255,195,77)', 'rgb(255,195,77)', 'rgb(194,225,5)', 'rgb(194,225,5)', 'rgb(194,225,5)', 'rgb(194,225,5)', 'rgb(0,150,0)', 'rgb(0,128,0)', 'rgb(0,128,0)', 'rgb(0,128,0)', 'rgb(255,69,0)', 'rgb(255,69,0)', 'rgb(255,69,0)', 'rgb(255,0,0)'];
}
}
/**
* Find all of the unique tracks (a combination of name and ID information)
* @param {Object} data
* @param {String} [rgb_field] A field that contains an RGB value. Aimed at BED files with an itemRgb column
* @private
* @returns {Array} All [unique_id, label, color] pairs in data. The unique_id is the thing used to define groupings
* most unambiguously.
*/
_generateCategoriesFromData(data, rgb_field) {
const self = this;
// Use the hard-coded legend if available (ignoring any mods on re-render)
const legend = this._base_layout.legend;
if (legend && legend.length) {
return legend.map((item) => [item[this.layout.track_split_field], item.label, item.color]);
}
// Generate options from data, if no preset legend exists
const unique_ids = {}; // make categories unique
const categories = [];
data.forEach((item) => {
const id = item[self.layout.track_split_field];
if (!Object.prototype.hasOwnProperty.call(unique_ids, id)) {
unique_ids[id] = null;
// If rgbfield is null, then the last entry is undefined/null as well
categories.push([id, item[this.layout.track_label_field], item[rgb_field]]);
}
});
return categories;
}
}
/**
* (**extension**) A basic tooltip with information to be shown over an intervals datum
* @alias module:LocusZoom_Layouts~standard_intervals
* @type tooltip
* @see {@link module:ext/lz-intervals-track} for required extension and installation instructions
*/
const intervals_tooltip_layout = {
closable: false,
show: { or: ['highlighted', 'selected'] },
hide: { and: ['unhighlighted', 'unselected'] },
html: '{{intervals:state_name|htmlescape}}<br>{{intervals:start|htmlescape}}-{{intervals:end|htmlescape}}',
};
/**
* (**extension**) A data layer with some preconfigured options for intervals display. This example was designed for chromHMM output,
* in which various states are assigned numeric state IDs and (<= as many) text state names.
*
* This layout is deprecated; most usages would be better served by the bed_intervals_layer layout instead.
* @alias module:LocusZoom_Layouts~intervals_layer
* @type data_layer
* @see {@link module:ext/lz-intervals-track} for required extension and installation instructions
*/
const intervals_layer_layout = {
namespace: { 'intervals': 'intervals' },
id: 'intervals',
type: 'intervals',
tag: 'intervals',
id_field: '{{intervals:start}}_{{intervals:end}}_{{intervals:state_name}}',
start_field: 'intervals:start',
end_field: 'intervals:end',
track_split_field: 'intervals:state_name',
track_label_field: 'intervals:state_name',
split_tracks: false,
always_hide_legend: true,
color: [
{
// If present, an explicit color field will override any other option (and be used to auto-generate legend)
field: 'intervals:itemRgb',
scale_function: 'to_rgb',
},
{
// TODO: Consider changing this to stable_choice in the future, for more stable coloring
field: 'intervals:state_name',
scale_function: 'categorical_bin',
parameters: {
// Placeholder. Empty categories and values will automatically be filled in when new data loads.
categories: [],
values: [],
null_value: '#B8B8B8',
},
},
],
legend: [], // Placeholder; auto-filled when data loads.
behaviors: {
onmouseover: [
{ action: 'set', status: 'highlighted' },
],
onmouseout: [
{ action: 'unset', status: 'highlighted' },
],
onclick: [
{ action: 'toggle', status: 'selected', exclusive: true },
],
onshiftclick: [
{ action: 'toggle', status: 'selected' },
],
},
tooltip: intervals_tooltip_layout,
};
/**
* (**extension**) A data layer with some preconfigured options for intervals display. This example was designed for standard BED3+ files and the field names emitted by the LzParsers extension.
* @alias module:LocusZoom_Layouts~bed_intervals_layer
* @type data_layer
* @see {@link module:ext/lz-intervals-track} for required extension and installation instructions
*/
const bed_intervals_layer_layout = LocusZoom.Layouts.merge({
id_field: '{{intervals:chromStart}}_{{intervals:chromEnd}}_{{intervals:name}}',
start_field: 'intervals:chromStart',
end_field: 'intervals:chromEnd',
track_split_field: 'intervals:name',
track_label_field: 'intervals:name',
split_tracks: true,
always_hide_legend: false,
color: [
{
// If present, an explicit color field will override any other option (and be used to auto-generate legend)
field: 'intervals:itemRgb',
scale_function: 'to_rgb',
},
{
// TODO: Consider changing this to stable_choice in the future, for more stable coloring
field: 'intervals:name',
scale_function: 'categorical_bin',
parameters: {
// Placeholder. Empty categories and values will automatically be filled in when new data loads.
categories: [],
values: [],
null_value: '#B8B8B8',
},
},
],
tooltip: LocusZoom.Layouts.merge({
html: `<strong>Group: </strong>{{intervals:name|htmlescape}}<br>
<strong>Region: </strong>{{intervals:chromStart|htmlescape}}-{{intervals:chromEnd|htmlescape}}
{{#if intervals:score}}<br>
<strong>Score:</strong> {{intervals:score|htmlescape}}{{/if}}`,
}, intervals_tooltip_layout),
}, intervals_layer_layout);
/**
* (**extension**) A panel containing an intervals data layer, eg for BED tracks. This is a legacy layout whose field names were specific to one partner site.
* @alias module:LocusZoom_Layouts~intervals
* @type panel
* @see {@link module:ext/lz-intervals-track} for required extension and installation instructions
*/
const intervals_panel_layout = {
id: 'intervals',
tag: 'intervals',
min_height: 50,
height: 50,
margin: { top: 25, right: 150, bottom: 5, left: 70 },
toolbar: (function () {
const l = LocusZoom.Layouts.get('toolbar', 'standard_panel');
l.widgets.push({
type: 'toggle_split_tracks',
data_layer_id: 'intervals',
position: 'right',
});
return l;
})(),
axes: {},
interaction: {
drag_background_to_pan: true,
scroll_to_zoom: true,
x_linked: true,
},
legend: {
hidden: true,
orientation: 'horizontal',
origin: { x: 50, y: 0 },
pad_from_bottom: 5,
},
data_layers: [intervals_layer_layout],
};
/**
* (**extension**) A panel containing an intervals data layer, eg for BED tracks. These field names match those returned by the LzParsers extension.
* @alias module:LocusZoom_Layouts~bed_intervals
* @type panel
* @see {@link module:ext/lz-intervals-track} for required extension and installation instructions
*/
const bed_intervals_panel_layout = LocusZoom.Layouts.merge({
// Normal BED tracks show the panel legend in collapsed mode!
min_height: 120,
height: 120,
data_layers: [bed_intervals_layer_layout],
}, intervals_panel_layout);
/**
* (**extension**) A plot layout that shows association summary statistics, genes, and interval data. This example assumes
* chromHMM data. (see panel layout) Few people will use the full intervals plot layout directly outside of an example.
* @alias module:LocusZoom_Layouts~interval_association
* @type plot
* @see {@link module:ext/lz-intervals-track} for required extension and installation instructions
*/
const intervals_plot_layout = {
state: {},
width: 800,
responsive_resize: true,
min_region_scale: 20000,
max_region_scale: 1000000,
toolbar: LocusZoom.Layouts.get('toolbar', 'standard_association'),
panels: [
LocusZoom.Layouts.get('panel', 'association'),
LocusZoom.Layouts.merge({ min_height: 120, height: 120 }, intervals_panel_layout),
LocusZoom.Layouts.get('panel', 'genes'),
],
};
LocusZoom.Adapters.add('IntervalLZ', IntervalLZ);
LocusZoom.DataLayers.add('intervals', LzIntervalsTrack);
LocusZoom.Layouts.add('tooltip', 'standard_intervals', intervals_tooltip_layout);
LocusZoom.Layouts.add('data_layer', 'intervals', intervals_layer_layout);
LocusZoom.Layouts.add('data_layer', 'bed_intervals', bed_intervals_layer_layout);
LocusZoom.Layouts.add('panel', 'intervals', intervals_panel_layout);
LocusZoom.Layouts.add('panel', 'bed_intervals', bed_intervals_panel_layout);
LocusZoom.Layouts.add('plot', 'interval_association', intervals_plot_layout);
LocusZoom.ScaleFunctions.add('to_rgb', to_rgb);
LocusZoom.Widgets.add('toggle_split_tracks', ToggleSplitTracks);
}
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;