import * as d3 from 'd3';
import {STATUSES} from './constants';
import Toolbar from './toolbar';
import {applyStyles, generateCurtain, generateLoader} from '../helpers/common';
import {parseFields, positionIntToString, prettyTicks} from '../helpers/display';
import {merge} from '../helpers/layouts';
import Legend from './legend';
import data_layers from '../registry/data_layers';
/**
* Default panel layout
* @memberof Panel
* @static
* @type {Object}
*/
const default_layout = {
id: '',
tag: 'custom_data_type',
title: { text: '', style: {}, x: 10, y: 22 },
y_index: null,
min_height: 1,
height: 1,
origin: { x: 0, y: null },
margin: { top: 0, right: 0, bottom: 0, left: 0 },
background_click: 'clear_selections',
toolbar: {
widgets: [],
},
cliparea: {
height: 0,
width: 0,
origin: { x: 0, y: 0 },
},
axes: { // These are the only axes supported!!
x: {},
y1: {},
y2: {},
},
legend: null,
interaction: {
drag_background_to_pan: false,
drag_x_ticks_to_scale: false,
drag_y1_ticks_to_scale: false,
drag_y2_ticks_to_scale: false,
scroll_to_zoom: false,
x_linked: false,
y1_linked: false,
y2_linked: false,
},
show_loading_indicator: true,
data_layers: [],
};
/**
* A panel is an abstract class representing a subdivision of the LocusZoom stage
* to display a distinct data representation as a collection of data layers.
*/
class Panel {
/**
* @param {string} layout.id An identifier string that must be unique across all panels in the plot. Required.
* @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 panel
* that shows association scatter plots, anywhere": even if the IDs are different, the tag can be the same.
* Most built-in panels will contain a tag that describes, in human-readable terms, what kind of data is being shown.
* (see: {@link LayoutRegistry.mutate_attrs})
* @param {boolean} [layout.show_loading_indicator=true] Whether to show a "loading indicator" while data is being fetched
* @param {module:LocusZoom_DataLayers[]} [layout.data_layers] Data layer layout objects
* @param {module:LocusZoom_Widgets[]} [layout.toolbar.widgets] Configuration options for each toolbar widget; {@link module:LocusZoom_Widgets}
* @param {number} [layout.title.text] Text to show in panel title
* @param {number} [layout.title.style] CSS options to apply to the title
* @param {number} [layout.title.x=10] x-offset for title position
* @param {number} [layout.title.y=22] y-offset for title position
* @param {'vertical'|'horizontal'} [layout.legend.orientation='vertical'] Orientation with which elements in the legend should be arranged.
* Presently only "vertical" and "horizontal" are supported values. When using the horizontal orientation
* elements will automatically drop to a new line if the width of the legend would exceed the right edge of the
* containing panel. Defaults to "vertical".
* @param {number} [layout.legend.origin.x=0] X-offset, in pixels, for the top-left corner of the legend (relative to the top left corner of the panel).
* @param {number} [layout.legend.origin.y=0] Y-offset, in pixels, for the top-left corner of the legend (relative to the top left corner of the panel).
* NOTE: SVG y values go from the top down, so the SVG origin of (0,0) is in the top left corner.
* @param {number} [layout.legend.padding=5] Value in pixels to pad between the legend's outer border and the
* elements within the legend. This value is also used for spacing between elements in the legend on different
* lines (e.g. in a vertical orientation) and spacing between element shapes and labels, as well as between
* elements in a horizontal orientation, are defined as a function of this value. Defaults to 5.
* @param {number} [layout.legend.label_size=12] Font size for element labels in the legend (loosely analogous to the height of full-height letters, in pixels). Defaults to 12.
* @param {boolean} [layout.legend.hidden=false] Whether to hide the legend by default
* @param {number} [layout.y_index] The position of the panel (above or below other panels). This is usually set
* automatically when the panel is added, and rarely controlled directly.
* @param {number} [layout.min_height=1] When resizing, do not allow height to go below this value
* @param {number} [layout.height=1] The actual height allocated to the panel (>= min_height)
* @param {number} [layout.margin.top=0] The margin (space between top of panel and edge of viewing area)
* @param {number} [layout.margin.right=0] The margin (space between right side of panel and edge of viewing area)
* @param {number} [layout.margin.bottom=0] The margin (space between bottom of panel and edge of viewing area)
* @param {number} [layout.margin.left=0] The margin (space between left side of panel and edge of viewing area)
* @param {'clear_selections'|null} [layout.background_click='clear_selections'] What happens when the background of the panel is clicked
* @param {'state'|null} [layout.axes.x.extent] If 'state', the x extent will be determined from plot.state (a
* shared region). Otherwise it will be determined based on data later ranges.
* @param {string} [layout.axes.x.label] Label text for the provided axis
* @param {number} [layout.axes.x.label_offset]
* @param {boolean} [layout.axes.x.render] Whether to render this axis
* @param {'region'|null} [layout.axes.x.tick_format] If 'region', format ticks in a concise way suitable for
* genomic coordinates, eg 23423456 => 23.42 (Mb)
* @param {Array} [layout.axes.x.ticks] An array of custom ticks that will override any automatically generated)
* @param {string} [layout.axes.y1.label] Label text for the provided axis
* @param {number} [layout.axes.y1.label_offset] The distance between the axis title and the axis. Use this to prevent
* the title from overlapping with tick mark labels. If there is not enough space for the label, be sure to increase the panel margins (left or right) accordingly.
* @param {boolean} [layout.axes.y1.render=false] Whether to render this axis
* @param {Array} [layout.axes.y1.ticks] An array of custom ticks that will override any automatically generated)
* @param {string} [layout.axes.y2.label] Label text for the provided axis
* @param {number} [layout.axes.y2.label_offset]
* @param {boolean} [layout.axes.y2.render=false] Whether to render this axis
* @param {Array} [layout.axes.y2.ticks] An array of custom ticks that will override any automatically generated)
* @param {boolean} [layout.interaction.drag_background_to_pan=false] Allow the user to drag the panel background to pan
* the plot to another genomic region.
* @param {boolean} [layout.interaction.drag_x_ticks_to_scale=false] Allow the user to rescale the x axis by dragging x ticks
* @param {boolean} [layout.interaction.drag_y1_ticks_to_scale=false] Allow the user to rescale the y1 axis by dragging y1 ticks
* @param {boolean} [layout.interaction.drag_y2_ticks_to_scale=false] Allow the user to rescale the y2 axis by dragging y2 ticks
* @param {boolean} [layout.interaction.scroll_to_zoom=false] Allow the user to rescale the plot by mousewheel-scrolling
* @param {boolean} [layout.interaction.x_linked=false] Whether this panel should change regions to match all other linked panels
* @param {boolean} [layout.interaction.y1_linked=false] Whether this panel should rescale to match all other linked panels
* @param {boolean} [layout.interaction.y2_linked=false] Whether this panel should rescale to match all other linked panels
* @param {Plot|null} parent
*/
constructor(layout, parent) {
if (typeof layout !== 'object') {
throw new Error('Unable to create panel, invalid layout');
}
/**
* @protected
* @member {Plot|null}
*/
this.parent = parent || null;
/**
* @protected
* @member {Plot|null}
*/
this.parent_plot = parent;
if (typeof layout.id !== 'string' || !layout.id) {
throw new Error('Panel layouts must specify "id"');
} else if (this.parent) {
if (typeof this.parent.panels[layout.id] !== 'undefined') {
throw new Error(`Cannot create panel with id [${layout.id}]; panel with that id already exists`);
}
}
/**
* @public
* @member {String}
*/
this.id = layout.id;
/**
* @private
* @member {Boolean}
*/
this._initialized = false;
/**
* The index of this panel in the parent plot's `layout.panels`
* @private
* @member {number}
* */
this._layout_idx = null;
/**
* @private
* @member {Object}
*/
this.svg = {};
/**
* A JSON-serializable object used to describe the composition of the Panel
* @public
* @member {Object}
*/
this.layout = merge(layout || {}, default_layout);
// Define state parameters specific to this panel
if (this.parent) {
/**
* @private
* @member {Object}
*/
this.state = this.parent.state;
/**
* @private
* @member {String}
*/
this._state_id = this.id;
this.state[this._state_id] = this.state[this._state_id] || {};
} else {
this.state = null;
this._state_id = null;
}
/**
* Direct access to data layer instances, keyed by data layer ID. Used primarily for introspection/ development.
* @public
* @member {Object.<String, BaseDataLayer>}
*/
this.data_layers = {};
/**
* @private
* @member {String[]}
*/
this._data_layer_ids_by_z_index = [];
/**
* Track data requests in progress
* @member {Promise[]}
* @private
*/
this._data_promises = [];
/**
* @private
* @member {d3.scale}
*/
this.x_scale = null;
/**
* @private
* @member {d3.scale}
*/
this.y1_scale = null;
/**
* @private
* @member {d3.scale}
*/
this.y2_scale = null;
/**
* @private
* @member {d3.extent}
*/
this.x_extent = null;
/**
* @private
* @member {d3.extent}
*/
this.y1_extent = null;
/**
* @private
* @member {d3.extent}
*/
this.y2_extent = null;
/**
* @private
* @member {Number[]}
*/
this.x_ticks = [];
/**
* @private
* @member {Number[]}
*/
this.y1_ticks = [];
/**
* @private
* @member {Number[]}
*/
this.y2_ticks = [];
/**
* A timeout ID as returned by setTimeout
* @private
* @member {number}
*/
this._zoom_timeout = null;
/**
* Known event hooks that the panel can respond to
* @see {@link event:any_lz_event} for a list of pre-defined events commonly used by LocusZoom
* @protected
* @member {Object}
*/
this._event_hooks = {};
// Initialize the layout
this.initializeLayout();
}
/******* Public methods: intended for direct external manipulation of panel internals */
/**
* There are several events that a LocusZoom panel can "emit" when appropriate, and LocusZoom supports registering
* "hooks" for these events which are essentially custom functions intended to fire at certain times.
*
* To register a hook for any of these events use `panel.on('event_name', function() {})`.
*
* There can be arbitrarily many functions registered to the same event. They will be executed in the order they
* were registered.
*
* @public
* @see {@link event:any_lz_event} for a list of pre-defined events commonly used by LocusZoom
* @param {String} event The name of the event. Consult documentation for the names of built-in events.
* @param {function} hook
* @returns {function} The registered event listener
*/
on(event, hook) {
// TODO: Dry plot and panel event code into a shared mixin
if (typeof event !== 'string') {
throw new Error(`Unable to register event hook. Event name must be a string: ${event.toString()}`);
}
if (typeof hook != 'function') {
throw new Error('Unable to register event hook, invalid hook function passed');
}
if (!this._event_hooks[event]) {
// We do not validate on known event names, because LZ is allowed to track and emit custom events like "widget button clicked".
this._event_hooks[event] = [];
}
this._event_hooks[event].push(hook);
return hook;
}
/**
* Remove one or more previously defined event listeners
* @public
* @param {String} event The name of an event (as defined in `event_hooks`)
* @param {eventCallback} [hook] The callback to deregister
* @returns {Panel}
*/
off(event, hook) {
const theseHooks = this._event_hooks[event];
if (typeof event != 'string' || !Array.isArray(theseHooks)) {
throw new Error(`Unable to remove event hook, invalid event: ${event.toString()}`);
}
if (hook === undefined) {
// Deregistering all hooks for this event may break basic functionality, and should only be used during
// cleanup operations (eg to prevent memory leaks)
this._event_hooks[event] = [];
} else {
const hookMatch = theseHooks.indexOf(hook);
if (hookMatch !== -1) {
theseHooks.splice(hookMatch, 1);
} else {
throw new Error('The specified event listener is not registered and therefore cannot be removed');
}
}
return this;
}
/**
* Handle running of event hooks when an event is emitted
*
* There is a shorter overloaded form of this method: if the event does not have any data, the second
* argument can be a boolean to control bubbling
*
* @public
* @see {@link event:any_lz_event} for a list of pre-defined events commonly used by LocusZoom
* @param {string} event A known event name
* @param {*} [eventData] Data or event description that will be passed to the event listener
* @param {boolean} [bubble=false] Whether to bubble the event to the parent
* @returns {Panel}
*/
emit(event, eventData, bubble) {
bubble = bubble || false;
// TODO: DRY this with the parent plot implementation. Ensure interfaces remain compatible.
// TODO: Improve documentation for overloaded method signature (JSDoc may have trouble here)
if (typeof event != 'string') {
throw new Error(`LocusZoom attempted to throw an invalid event: ${event.toString()}`);
}
if (typeof eventData === 'boolean' && arguments.length === 2) {
// Overloaded method signature: emit(event, bubble)
bubble = eventData;
eventData = null;
}
const sourceID = this.getBaseId();
const eventContext = { sourceID: sourceID, target: this, data: eventData || null };
if (this._event_hooks[event]) {
// If the tree_fall event is emitted in a forest and no one is around to hear it, does it really make a sound?
this._event_hooks[event].forEach((hookToRun) => {
// By default, any handlers fired here will see the panel as the value of `this`. If a bound function is
// registered as a handler, the previously bound `this` will override anything provided to `call` below.
hookToRun.call(this, eventContext);
});
}
if (bubble && this.parent) {
// Even if this event has no listeners locally, it might still have listeners on the parent
this.parent.emit(event, eventContext);
}
return this;
}
/**
* Set the title for the panel. If passed an object, will merge the object with the existing layout configuration, so
* that all or only some of the title layout object's parameters can be customized. If passed null, false, or an empty
* string, the title DOM element will be set to display: none.
*
* @public
* @param {string|object|null} title The title text, or an object with additional configuration
* @param {string} title.text Text to display. Since titles are rendered as SVG text, HTML and newlines will not be rendered.
* @param {number} title.x X-offset, in pixels, for the title's text anchor (default left) relative to the top-left corner of the panel.
* @param {number} title.y Y-offset, in pixels, for the title's text anchor (default left) relative to the top-left corner of the panel.
NOTE: SVG y values go from the top down, so the SVG origin of (0,0) is in the top left corner.
* @param {object} title.style CSS styles object to be applied to the title's DOM element.
* @returns {Panel}
*/
setTitle(title) {
if (typeof this.layout.title == 'string') {
const text = this.layout.title;
this.layout.title = { text: text, x: 0, y: 0, style: {} };
}
if (typeof title == 'string') {
this.layout.title.text = title;
} else if (typeof title == 'object' && title !== null) {
this.layout.title = merge(title, this.layout.title);
}
if (this.layout.title.text.length) {
this.title
.attr('display', null)
.attr('x', parseFloat(this.layout.title.x))
.attr('y', parseFloat(this.layout.title.y))
.text(this.layout.title.text)
.call(applyStyles, this.layout.title.style);
} else {
this.title.attr('display', 'none');
}
return this;
}
/**
* Create a new data layer from a provided layout object. Should have the keys specified in `DefaultLayout`
* Will automatically add at the top (depth/z-index) of the panel unless explicitly directed differently
* in the layout provided.
*
* **NOTE**: It is very rare that new data layers are added after a panel is rendered.
* @public
* @param {object} layout
* @returns {BaseDataLayer}
*/
addDataLayer(layout) {
// Sanity checks
if (typeof layout !== 'object' || typeof layout.id !== 'string' || !layout.id.length) {
throw new Error('Invalid data layer layout');
}
if (typeof this.data_layers[layout.id] !== 'undefined') {
throw new Error(`Cannot create data_layer with id '${layout.id}'; data layer with that id already exists in the panel`);
}
if (typeof layout.type !== 'string') {
throw new Error('Invalid data layer type');
}
// If the layout defines a y axis make sure the axis number is set and is 1 or 2 (default to 1)
if (typeof layout.y_axis == 'object' && (typeof layout.y_axis.axis == 'undefined' || ![1, 2].includes(layout.y_axis.axis))) {
layout.y_axis.axis = 1;
}
// Create the Data Layer
const data_layer = data_layers.create(layout.type, layout, this);
// Store the Data Layer on the Panel
this.data_layers[data_layer.id] = data_layer;
// If a discrete z_index was set in the layout then adjust other data layer z_index values to accommodate this one
if (data_layer.layout.z_index !== null && !isNaN(data_layer.layout.z_index)
&& this._data_layer_ids_by_z_index.length > 0) {
// Negative z_index values should count backwards from the end, so convert negatives to appropriate values here
if (data_layer.layout.z_index < 0) {
data_layer.layout.z_index = Math.max(this._data_layer_ids_by_z_index.length + data_layer.layout.z_index, 0);
}
this._data_layer_ids_by_z_index.splice(data_layer.layout.z_index, 0, data_layer.id);
this._data_layer_ids_by_z_index.forEach((dlid, idx) => {
this.data_layers[dlid].layout.z_index = idx;
});
} else {
const length = this._data_layer_ids_by_z_index.push(data_layer.id);
this.data_layers[data_layer.id].layout.z_index = length - 1;
}
// Determine if this data layer was already in the layout.data_layers array.
// If it wasn't, add it. Either way store the layout.data_layers array index on the data_layer.
let layout_idx = null;
this.layout.data_layers.forEach((data_layer_layout, idx) => {
if (data_layer_layout.id === data_layer.id) {
layout_idx = idx;
}
});
if (layout_idx === null) {
layout_idx = this.layout.data_layers.push(this.data_layers[data_layer.id].layout) - 1;
}
this.data_layers[data_layer.id]._layout_idx = layout_idx;
return this.data_layers[data_layer.id];
}
/**
* Remove a data layer by id
* @public
* @param {string} id
* @returns {Panel}
*/
removeDataLayer(id) {
const target_layer = this.data_layers[id];
if (!target_layer) {
throw new Error(`Unable to remove data layer, ID not found: ${id}`);
}
// Destroy all tooltips for the data layer
target_layer.destroyAllTooltips();
// Remove the svg container for the data layer if it exists
if (target_layer.svg.container) {
target_layer.svg.container.remove();
}
// Delete the data layer and its presence in the panel layout and state
this.layout.data_layers.splice(target_layer._layout_idx, 1);
delete this.state[target_layer._state_id];
delete this.data_layers[id];
// Remove the data_layer id from the z_index array
this._data_layer_ids_by_z_index.splice(this._data_layer_ids_by_z_index.indexOf(id), 1);
// Update layout_idx and layout.z_index values for all remaining data_layers
this.applyDataLayerZIndexesToDataLayerLayouts();
this.layout.data_layers.forEach((data_layer_layout, idx) => {
this.data_layers[data_layer_layout.id]._layout_idx = idx;
});
return this;
}
/**
* Clear all selections on all data layers
* @public
* @returns {Panel}
*/
clearSelections() {
this._data_layer_ids_by_z_index.forEach((id) => {
this.data_layers[id].setAllElementStatus('selected', false);
});
return this;
}
/**
* Update rendering of this panel whenever an event triggers a redraw. Assumes that the panel has already been
* prepared the first time via `initialize`
* @public
* @returns {Panel}
*/
render() {
// Position the panel container
this.svg.container.attr('transform', `translate(${this.layout.origin.x}, ${this.layout.origin.y})`);
// Set size on the clip rect
this.svg.clipRect
.attr('width', this.parent_plot.layout.width)
.attr('height', this.layout.height);
const { cliparea } = this.layout;
// Set and position the inner border, style if necessary
const { margin } = this.layout;
this.inner_border
.attr('x', margin.left)
.attr('y', margin.top)
.attr('width', this.parent_plot.layout.width - (margin.left + margin.right))
.attr('height', this.layout.height - (margin.top + margin.bottom));
if (this.layout.inner_border) {
this.inner_border
.style('stroke-width', 1)
.style('stroke', this.layout.inner_border);
}
// Set/update panel title if necessary
this.setTitle();
// Regenerate all extents
this.generateExtents();
// Helper function to constrain any procedurally generated vectors (e.g. ranges, extents)
// Constraints applied here keep vectors from going to infinity or beyond a definable power of ten
const constrain = function (value, limit_exponent) {
const neg_min = Math.pow(-10, limit_exponent);
const neg_max = Math.pow(-10, -limit_exponent);
const pos_min = Math.pow(10, -limit_exponent);
const pos_max = Math.pow(10, limit_exponent);
if (value === Infinity) {
value = pos_max;
}
if (value === -Infinity) {
value = neg_min;
}
if (value === 0) {
value = pos_min;
}
if (value > 0) {
value = Math.max(Math.min(value, pos_max), pos_min);
}
if (value < 0) {
value = Math.max(Math.min(value, neg_max), neg_min);
}
return value;
};
// Define default and shifted ranges for all axes
const ranges = {};
const axes_config = this.layout.axes;
if (this.x_extent) {
const base_x_range = { start: 0, end: this.layout.cliparea.width };
if (axes_config.x.range) {
base_x_range.start = axes_config.x.range.start || base_x_range.start;
base_x_range.end = axes_config.x.range.end || base_x_range.end;
}
ranges.x = [base_x_range.start, base_x_range.end];
ranges.x_shifted = [base_x_range.start, base_x_range.end];
}
if (this.y1_extent) {
const base_y1_range = { start: cliparea.height, end: 0 };
if (axes_config.y1.range) {
base_y1_range.start = axes_config.y1.range.start || base_y1_range.start;
base_y1_range.end = axes_config.y1.range.end || base_y1_range.end;
}
ranges.y1 = [base_y1_range.start, base_y1_range.end];
ranges.y1_shifted = [base_y1_range.start, base_y1_range.end];
}
if (this.y2_extent) {
const base_y2_range = { start: cliparea.height, end: 0 };
if (axes_config.y2.range) {
base_y2_range.start = axes_config.y2.range.start || base_y2_range.start;
base_y2_range.end = axes_config.y2.range.end || base_y2_range.end;
}
ranges.y2 = [base_y2_range.start, base_y2_range.end];
ranges.y2_shifted = [base_y2_range.start, base_y2_range.end];
}
// Shift ranges based on any drag or zoom interactions currently underway
let { _interaction } = this.parent;
const current_drag = _interaction.dragging;
if (_interaction.panel_id && (_interaction.panel_id === this.id || _interaction.linked_panel_ids.includes(this.id))) {
let anchor, scalar = null;
if (_interaction.zooming && typeof this.x_scale == 'function') {
const current_extent_size = Math.abs(this.x_extent[1] - this.x_extent[0]);
const current_scaled_extent_size = Math.round(this.x_scale.invert(ranges.x_shifted[1])) - Math.round(this.x_scale.invert(ranges.x_shifted[0]));
let zoom_factor = _interaction.zooming.scale;
const potential_extent_size = Math.floor(current_scaled_extent_size * (1 / zoom_factor));
if (zoom_factor < 1 && !isNaN(this.parent.layout.max_region_scale)) {
zoom_factor = 1 / (Math.min(potential_extent_size, this.parent.layout.max_region_scale) / current_scaled_extent_size);
} else if (zoom_factor > 1 && !isNaN(this.parent.layout.min_region_scale)) {
zoom_factor = 1 / (Math.max(potential_extent_size, this.parent.layout.min_region_scale) / current_scaled_extent_size);
}
const new_extent_size = Math.floor(current_extent_size * zoom_factor);
anchor = _interaction.zooming.center - margin.left - this.layout.origin.x;
const offset_ratio = anchor / cliparea.width;
const new_x_extent_start = Math.max(Math.floor(this.x_scale.invert(ranges.x_shifted[0]) - ((new_extent_size - current_scaled_extent_size) * offset_ratio)), 1);
ranges.x_shifted = [ this.x_scale(new_x_extent_start), this.x_scale(new_x_extent_start + new_extent_size) ];
} else if (current_drag) {
switch (current_drag.method) {
case 'background':
ranges.x_shifted[0] = +current_drag.dragged_x;
ranges.x_shifted[1] = cliparea.width + current_drag.dragged_x;
break;
case 'x_tick':
if (d3.event && d3.event.shiftKey) {
ranges.x_shifted[0] = +current_drag.dragged_x;
ranges.x_shifted[1] = cliparea.width + current_drag.dragged_x;
} else {
anchor = current_drag.start_x - margin.left - this.layout.origin.x;
scalar = constrain(anchor / (anchor + current_drag.dragged_x), 3);
ranges.x_shifted[0] = 0;
ranges.x_shifted[1] = Math.max(cliparea.width * (1 / scalar), 1);
}
break;
case 'y1_tick':
case 'y2_tick': {
const y_shifted = `y${current_drag.method[1]}_shifted`;
if (d3.event && d3.event.shiftKey) {
ranges[y_shifted][0] = cliparea.height + current_drag.dragged_y;
ranges[y_shifted][1] = +current_drag.dragged_y;
} else {
anchor = cliparea.height - (current_drag.start_y - margin.top - this.layout.origin.y);
scalar = constrain(anchor / (anchor - current_drag.dragged_y), 3);
ranges[y_shifted][0] = cliparea.height;
ranges[y_shifted][1] = cliparea.height - (cliparea.height * (1 / scalar));
}
}
}
}
}
// Generate scales and ticks for all axes, then render them
['x', 'y1', 'y2'].forEach((axis) => {
if (!this[`${axis}_extent`]) {
return;
}
// Base Scale
this[`${axis}_scale`] = d3.scaleLinear()
.domain(this[`${axis}_extent`])
.range(ranges[`${axis}_shifted`]);
// Shift the extent
this[`${axis}_extent`] = [
this[`${axis}_scale`].invert(ranges[axis][0]),
this[`${axis}_scale`].invert(ranges[axis][1]),
];
// Finalize Scale
this[`${axis}_scale`] = d3.scaleLinear()
.domain(this[`${axis}_extent`]).range(ranges[axis]);
// Render axis (and generate ticks as needed)
this.renderAxis(axis);
});
// Establish mousewheel zoom event handers on the panel (namespacing not passed through by d3, so not used here)
if (this.layout.interaction.scroll_to_zoom) {
const zoom_handler = () => {
// Look for a shift key press while scrolling to execute.
// If not present, gracefully raise a notification and allow conventional scrolling
if (!(d3.event.shiftKey || d3.event.altKey)) {
if (this.parent._canInteract(this.id)) {
this.loader.show('Press <tt>[SHIFT]</tt> or <tt>[ALT]</tt> while scrolling to zoom').hide(1000);
}
return;
}
d3.event.preventDefault();
if (!this.parent._canInteract(this.id)) {
return;
}
const coords = d3.mouse(this.svg.container.node());
const delta = Math.max(-1, Math.min(1, (d3.event.wheelDelta || -d3.event.detail || -d3.event.deltaY)));
if (delta === 0) {
return;
}
this.parent._interaction = {
panel_id: this.id,
linked_panel_ids: this.getLinkedPanelIds('x'),
zooming: {
scale: (delta < 1) ? 0.9 : 1.1,
center: coords[0],
},
};
this.render();
// Redefine b/c might have been changed during call to parent re-render
_interaction = this.parent._interaction;
_interaction.linked_panel_ids.forEach((panel_id) => {
this.parent.panels[panel_id].render();
});
if (this._zoom_timeout !== null) {
clearTimeout(this._zoom_timeout);
}
this._zoom_timeout = setTimeout(() => {
this.parent._interaction = {};
this.parent.applyState({ start: this.x_extent[0], end: this.x_extent[1] });
}, 500);
};
// FIXME: Consider moving back to d3.zoom and rewriting drag + zoom to use behaviors.
this.svg.container
.on('wheel.zoom', zoom_handler)
.on('mousewheel.zoom', zoom_handler)
.on('DOMMouseScroll.zoom', zoom_handler);
}
// Render data layers in order by z-index
this._data_layer_ids_by_z_index.forEach((data_layer_id) => {
this.data_layers[data_layer_id].draw().render();
});
// Rerender legend last (on top of data). A legend must have been defined at the start in order for this to work.
if (this.legend) {
this.legend.render();
}
return this;
}
/**
* Add a "basic" loader to a panel. This is rarely used directly: the `show_loading_indicator` panel layout
* directive is the preferred way to trigger this function. The imperative form is useful if for some reason a
* loading indicator needs to be added only after first render.
* This method is just a shortcut for adding the most commonly used type of loading indicator, which appears when
* data is requested, animates (e.g. shows an infinitely cycling progress bar as opposed to one that loads from
* 0-100% based on actual load progress), and disappears when new data is loaded and rendered.
*
* @protected
* @listens event:data_requested
* @listens event:data_rendered
* @param {Boolean} show_immediately
* @returns {Panel}
*/
addBasicLoader(show_immediately = true) {
if (this.layout.show_loading_indicator && this._initialized) {
// Prior to LZ 0.13, this function was called only after the plot was first rendered. Now, it is run by default.
// Some older pages could thus end up adding a loader twice: to avoid duplicate render events,
// short-circuit if a loader is already present after the first render has finished.
return this;
}
if (show_immediately) {
this.loader.show('Loading...').animate();
}
this.on('data_requested', () => {
this.loader.show('Loading...').animate();
});
this.on('data_rendered', () => {
this.loader.hide();
});
// Update layout to reflect new option
this.layout.show_loading_indicator = true;
return this;
}
/************* Private interface: only used internally */
/** @private */
applyDataLayerZIndexesToDataLayerLayouts () {
this._data_layer_ids_by_z_index.forEach((dlid, idx) => {
this.data_layers[dlid].layout.z_index = idx;
});
}
/**
* @private
* @returns {string}
*/
getBaseId () {
return `${this.parent.id}.${this.id}`;
}
/**
* 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 plot_origin = this.parent._getPageOrigin();
return {
x: plot_origin.x + this.layout.origin.x,
y: plot_origin.y + this.layout.origin.y,
};
}
/**
* Prepare the panel for first use by performing parameter validation, creating axes, setting default dimensions,
* and preparing / positioning data layers as appropriate.
* @private
* @returns {Panel}
*/
initializeLayout() {
// Set panel dimensions, origin, and margin
this.setDimensions();
this.setOrigin();
this.setMargin();
// Set ranges
// TODO: Define stub values in constructor
this.x_range = [0, this.layout.cliparea.width];
this.y1_range = [this.layout.cliparea.height, 0];
this.y2_range = [this.layout.cliparea.height, 0];
// Initialize panel axes
['x', 'y1', 'y2'].forEach((id) => {
const axis = this.layout.axes[id];
if (!Object.keys(axis).length || axis.render === false) {
// The default layout sets the axis to an empty object, so set its render boolean here
axis.render = false;
} else {
axis.render = true;
axis.label = axis.label || null;
}
});
// Add data layers (which define x and y extents)
this.layout.data_layers.forEach((data_layer_layout) => {
this.addDataLayer(data_layer_layout);
});
return this;
}
/**
* Set the dimensions for the panel. If passed with no arguments will calculate optimal size based on layout
* directives and the available area within the plot. If passed discrete width (number) and height (number) will
* attempt to resize the panel to them, but may be limited by minimum dimensions defined on the plot or panel.
*
* @private
* @param {number} [width]
* @param {number} [height]
* @returns {Panel}
*/
setDimensions(width, height) {
const layout = this.layout;
if (typeof width != 'undefined' && typeof height != 'undefined') {
if (!isNaN(width) && width >= 0 && !isNaN(height) && height >= 0) {
this.parent.layout.width = Math.round(+width);
// Ensure that the requested height satisfies all minimum values
layout.height = Math.max(Math.round(+height), layout.min_height);
}
}
layout.cliparea.width = Math.max(this.parent_plot.layout.width - (layout.margin.left + layout.margin.right), 0);
layout.cliparea.height = Math.max(layout.height - (layout.margin.top + layout.margin.bottom), 0);
if (this.svg.clipRect) {
this.svg.clipRect
.attr('width', this.parent.layout.width)
.attr('height', layout.height);
}
if (this._initialized) {
this.render();
this.curtain.update();
this.loader.update();
this.toolbar.update();
if (this.legend) {
this.legend.position();
}
}
return this;
}
/**
* Set panel origin on the plot, and re-render as appropriate
*
* @private
* @param {number} x
* @param {number} y
* @returns {Panel}
*/
setOrigin(x, y) {
if (!isNaN(x) && x >= 0) {
this.layout.origin.x = Math.max(Math.round(+x), 0);
}
if (!isNaN(y) && y >= 0) {
this.layout.origin.y = Math.max(Math.round(+y), 0);
}
if (this._initialized) {
this.render();
}
return this;
}
/**
* Set margins around this panel
* @private
* @param {number} top
* @param {number} right
* @param {number} bottom
* @param {number} left
* @returns {Panel}
*/
setMargin(top, right, bottom, left) {
let extra;
const { cliparea, margin } = this.layout;
if (!isNaN(top) && top >= 0) {
margin.top = Math.max(Math.round(+top), 0);
}
if (!isNaN(right) && right >= 0) {
margin.right = Math.max(Math.round(+right), 0);
}
if (!isNaN(bottom) && bottom >= 0) {
margin.bottom = Math.max(Math.round(+bottom), 0);
}
if (!isNaN(left) && left >= 0) {
margin.left = Math.max(Math.round(+left), 0);
}
// If the specified margins are greater than the available width, then shrink the margins.
if (margin.top + margin.bottom > this.layout.height) {
extra = Math.floor(((margin.top + margin.bottom) - this.layout.height) / 2);
margin.top -= extra;
margin.bottom -= extra;
}
if (margin.left + margin.right > this.parent_plot.layout.width) {
extra = Math.floor(((margin.left + margin.right) - this.parent_plot.layout.width) / 2);
margin.left -= extra;
margin.right -= extra;
}
['top', 'right', 'bottom', 'left'].forEach((m) => {
margin[m] = Math.max(margin[m], 0);
});
cliparea.width = Math.max(this.parent_plot.layout.width - (margin.left + margin.right), 0);
cliparea.height = Math.max(this.layout.height - (margin.top + margin.bottom), 0);
cliparea.origin.x = margin.left;
cliparea.origin.y = margin.top;
if (this._initialized) {
this.render();
}
return this;
}
/**
* Prepare the first rendering of the panel. This includes drawing the individual data layers, but also creates shared
* elements such as axes, title, and loader/curtain.
* @private
* @returns {Panel}
*/
initialize() {
// Append a container group element to house the main panel group element and the clip path
// Position with initial layout parameters
const base_id = this.getBaseId();
this.svg.container = this.parent.svg.append('g')
.attr('id', `${base_id}.panel_container`)
.attr('transform', `translate(${this.layout.origin.x || 0}, ${this.layout.origin.y || 0})`);
// Append clip path to the parent svg element, size with initial layout parameters
const clipPath = this.svg.container.append('clipPath')
.attr('id', `${base_id}.clip`);
this.svg.clipRect = clipPath.append('rect')
.attr('width', this.parent_plot.layout.width)
.attr('height', this.layout.height);
// Append svg group for rendering all panel child elements, clipped by the clip path
this.svg.group = this.svg.container.append('g')
.attr('id', `${base_id}.panel`)
.attr('clip-path', `url(#${base_id}.clip)`);
// Add curtain and loader to the panel
/**
* @protected
* @member {Object}
*/
this.curtain = generateCurtain.call(this);
/**
* @protected
* @member {Object}
*/
this.loader = generateLoader.call(this);
if (this.layout.show_loading_indicator) {
// Activate the loading indicator prior to first render, and only show when data is loading
this.addBasicLoader(false);
}
/**
* Create the toolbar object and hang widgets on it as defined by panel layout
* @protected
* @member {Toolbar}
*/
this.toolbar = new Toolbar(this);
// Inner border
this.inner_border = this.svg.group.append('rect')
.attr('class', 'lz-panel-background')
.on('click', () => {
if (this.layout.background_click === 'clear_selections') {
this.clearSelections();
}
});
// Add the title
/**
* @private
* @member {Element}
*/
this.title = this.svg.group.append('text').attr('class', 'lz-panel-title');
if (typeof this.layout.title != 'undefined') {
this.setTitle();
}
// Initialize Axes
this.svg.x_axis = this.svg.group.append('g')
.attr('id', `${base_id}.x_axis`)
.attr('class', 'lz-x lz-axis');
if (this.layout.axes.x.render) {
this.svg.x_axis_label = this.svg.x_axis.append('text')
.attr('class', 'lz-x lz-axis lz-label')
.attr('text-anchor', 'middle');
}
this.svg.y1_axis = this.svg.group.append('g')
.attr('id', `${base_id}.y1_axis`).attr('class', 'lz-y lz-y1 lz-axis');
if (this.layout.axes.y1.render) {
this.svg.y1_axis_label = this.svg.y1_axis.append('text')
.attr('class', 'lz-y1 lz-axis lz-label')
.attr('text-anchor', 'middle');
}
this.svg.y2_axis = this.svg.group.append('g')
.attr('id', `${base_id}.y2_axis`)
.attr('class', 'lz-y lz-y2 lz-axis');
if (this.layout.axes.y2.render) {
this.svg.y2_axis_label = this.svg.y2_axis.append('text')
.attr('class', 'lz-y2 lz-axis lz-label')
.attr('text-anchor', 'middle');
}
// Initialize child Data Layers
this._data_layer_ids_by_z_index.forEach((id) => {
this.data_layers[id].initialize();
});
/**
* Legend object, as defined by panel layout and child data layer layouts
* @protected
* @member {Legend}
* */
this.legend = null;
if (this.layout.legend) {
this.legend = new Legend(this);
}
// Establish panel background drag interaction mousedown event handler (on the panel background)
if (this.layout.interaction.drag_background_to_pan) {
const namespace = `.${this.parent.id}.${this.id}.interaction.drag`;
const mousedown = () => this.parent.startDrag(this, 'background');
this.svg.container.select('.lz-panel-background')
.on(`mousedown${namespace}.background`, mousedown)
.on(`touchstart${namespace}.background`, mousedown);
}
return this;
}
/**
* Refresh the sort order of all data layers (called by data layer moveForward and moveBack methods)
* @private
*/
resortDataLayers() {
const sort = [];
this._data_layer_ids_by_z_index.forEach((id) => {
sort.push(this.data_layers[id].layout.z_index);
});
this.svg.group
.selectAll('g.lz-data_layer-container')
.data(sort)
.sort(d3.ascending);
this.applyDataLayerZIndexesToDataLayerLayouts();
}
/**
* Get an array of panel IDs that are axis-linked to this panel
* @private
* @param {('x'|'y1'|'y2')} axis
* @returns {Array}
*/
getLinkedPanelIds(axis) {
axis = axis || null;
const linked_panel_ids = [];
if (!['x', 'y1', 'y2'].includes(axis)) {
return linked_panel_ids;
}
if (!this.layout.interaction[`${axis}_linked`]) {
return linked_panel_ids;
}
this.parent._panel_ids_by_y_index.forEach((panel_id) => {
if (panel_id !== this.id && this.parent.panels[panel_id].layout.interaction[`${axis}_linked`]) {
linked_panel_ids.push(panel_id);
}
});
return linked_panel_ids;
}
/**
* Move a panel up relative to others by y-index
* @private
* @returns {Panel}
*/
moveUp() {
const { parent } = this;
const y_index = this.layout.y_index;
if (parent._panel_ids_by_y_index[y_index - 1]) {
parent._panel_ids_by_y_index[y_index] = parent._panel_ids_by_y_index[y_index - 1];
parent._panel_ids_by_y_index[y_index - 1] = this.id;
parent.applyPanelYIndexesToPanelLayouts();
parent.positionPanels();
}
return this;
}
/**
* Move a panel down (y-axis) relative to others in the plot
* @private
* @returns {Panel}
*/
moveDown() {
const { _panel_ids_by_y_index } = this.parent;
if (_panel_ids_by_y_index[this.layout.y_index + 1]) {
_panel_ids_by_y_index[this.layout.y_index] = _panel_ids_by_y_index[this.layout.y_index + 1];
_panel_ids_by_y_index[this.layout.y_index + 1] = this.id;
this.parent.applyPanelYIndexesToPanelLayouts();
this.parent.positionPanels();
}
return this;
}
/**
* When the parent plot changes state, adjust the panel accordingly. For example, this may include fetching new data
* from the API as the viewing region changes
* @private
* @fires event:data_requested
* @fires event:layout_changed
* @fires event:data_rendered
* @returns {Promise}
*/
reMap() {
this.emit('data_requested');
this._data_promises = [];
// Remove any previous error messages before attempting to load new data
this.curtain.hide();
// Trigger reMap on each Data Layer
for (let id in this.data_layers) {
try {
this._data_promises.push(this.data_layers[id].reMap());
} catch (error) {
console.error(error);
this.curtain.show(error.message || error);
}
}
// When all finished trigger a render
return Promise.all(this._data_promises)
.then(() => {
this._initialized = true;
this.render();
this.emit('layout_changed', true);
this.emit('data_rendered');
})
.catch((error) => {
console.error(error);
this.curtain.show(error.message || error);
});
}
/**
* Iterate over data layers to generate panel axis extents
* @private
* @returns {Panel}
*/
generateExtents() {
// Reset extents
['x', 'y1', 'y2'].forEach((axis) => {
this[`${axis}_extent`] = null;
});
// Loop through the data layers
for (let id in this.data_layers) {
const data_layer = this.data_layers[id];
// If defined and not decoupled, merge the x extent of the data layer with the panel's x extent
if (data_layer.layout.x_axis && !data_layer.layout.x_axis.decoupled) {
this.x_extent = d3.extent((this.x_extent || []).concat(data_layer.getAxisExtent('x')));
}
// If defined and not decoupled, merge the y extent of the data layer with the panel's appropriate y extent
if (data_layer.layout.y_axis && !data_layer.layout.y_axis.decoupled) {
const y_axis = `y${data_layer.layout.y_axis.axis}`;
this[`${y_axis}_extent`] = d3.extent((this[`${y_axis}_extent`] || []).concat(data_layer.getAxisExtent('y')));
}
}
// Override x_extent from state if explicitly defined to do so
if (this.layout.axes.x && this.layout.axes.x.extent === 'state') {
this.x_extent = [ this.state.start, this.state.end ];
}
return this;
}
/**
* Generate an array of ticks for an axis. These ticks are generated in one of three ways (highest wins):
* 1. An array of specific tick marks
* 2. Query each data layer for what ticks are appropriate, and allow a panel-level tick configuration parameter
* object to override the layer's default presentation settings
* 3. Generate generic tick marks based on the extent of the data
*
* @private
* @param {('x'|'y1'|'y2')} axis The string identifier of the axis
* @returns {Number[]|Object[]} TODO: number format?
* An array of numbers: interpreted as an array of axis value offsets for positioning.
* 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
*/
generateTicks(axis) {
// Parse an explicit 'ticks' attribute in the axis layout
if (this.layout.axes[axis].ticks) {
const layout = this.layout.axes[axis];
const baseTickConfig = layout.ticks;
if (Array.isArray(baseTickConfig)) {
// Array of specific ticks hard-coded into a panel will override any ticks that an individual layer might specify
return baseTickConfig;
}
if (typeof baseTickConfig === 'object') {
// If the layout specifies base configuration for ticks- but without specific positions- then ask each
// data layer to report the tick marks that it thinks it needs
// TODO: Few layers currently need to specify custom ticks (which is ok!). But if it becomes common, consider adding mechanisms to deduplicate ticks across layers
const self = this;
// Pass any layer-specific customizations for how ticks are calculated. (styles are overridden separately)
const config = { position: baseTickConfig.position };
const combinedTicks = this._data_layer_ids_by_z_index.reduce((acc, data_layer_id) => {
const nextLayer = self.data_layers[data_layer_id];
return acc.concat(nextLayer.getTicks(axis, config));
}, []);
return combinedTicks.map((item) => {
// The layer makes suggestions, but tick configuration params specified on the panel take precedence
let itemConfig = {};
itemConfig = merge(itemConfig, baseTickConfig);
return merge(itemConfig, item);
});
}
}
// If no other configuration is provided, attempt to generate ticks from the extent
if (this[`${axis}_extent`]) {
return prettyTicks(this[`${axis}_extent`], 'both');
}
return [];
}
/**
* Render ticks for a particular axis
* @private
* @param {('x'|'y1'|'y2')} axis The identifier of the axes
* @returns {Panel}
*/
renderAxis(axis) {
if (!['x', 'y1', 'y2'].includes(axis)) {
throw new Error(`Unable to render axis; invalid axis identifier: ${axis}`);
}
const canRender = this.layout.axes[axis].render
&& typeof this[`${axis}_scale`] == 'function'
&& !isNaN(this[`${axis}_scale`](0));
// If the axis has already been rendered then check if we can/can't render it
// Make sure the axis element is shown/hidden to suit
if (this[`${axis}_axis`]) {
this.svg.container.select(`g.lz-axis.lz-${axis}`)
.style('display', canRender ? null : 'none');
}
if (!canRender) {
return this;
}
// Axis-specific values to plug in where needed
const axis_params = {
x: {
position: `translate(${this.layout.margin.left}, ${this.layout.height - this.layout.margin.bottom})`,
orientation: 'bottom',
label_x: this.layout.cliparea.width / 2,
label_y: (this.layout.axes[axis].label_offset || 0),
label_rotate: null,
},
y1: {
position: `translate(${this.layout.margin.left}, ${this.layout.margin.top})`,
orientation: 'left',
label_x: -1 * (this.layout.axes[axis].label_offset || 0),
label_y: this.layout.cliparea.height / 2,
label_rotate: -90,
},
y2: {
position: `translate(${this.parent_plot.layout.width - this.layout.margin.right}, ${this.layout.margin.top})`,
orientation: 'right',
label_x: (this.layout.axes[axis].label_offset || 0),
label_y: this.layout.cliparea.height / 2,
label_rotate: -90,
},
};
// Generate Ticks
this[`${axis}_ticks`] = this.generateTicks(axis);
// Determine if the ticks are all numbers (d3-automated tick rendering) or not (manual tick rendering)
const ticksAreAllNumbers = ((ticks) => {
for (let i = 0; i < ticks.length; i++) {
if (isNaN(ticks[i])) {
return false;
}
}
return true;
})(this[`${axis}_ticks`]);
// Initialize the axis; set scale and orientation
let axis_factory;
switch (axis_params[axis].orientation) {
case 'right':
axis_factory = d3.axisRight;
break;
case 'left':
axis_factory = d3.axisLeft;
break;
case 'bottom':
axis_factory = d3.axisBottom;
break;
default:
throw new Error('Unrecognized axis orientation');
}
this[`${axis}_axis`] = axis_factory(this[`${axis}_scale`])
.tickPadding(3);
// Set tick values and format
if (ticksAreAllNumbers) {
this[`${axis}_axis`].tickValues(this[`${axis}_ticks`]);
if (this.layout.axes[axis].tick_format === 'region') {
this[`${axis}_axis`].tickFormat((d) => positionIntToString(d, 6));
}
} else {
let ticks = this[`${axis}_ticks`].map((t) => {
return (t[axis.substr(0, 1)]);
});
this[`${axis}_axis`].tickValues(ticks)
.tickFormat((t, i) => {
return this[`${axis}_ticks`][i].text;
});
}
// Position the axis in the SVG and apply the axis construct
this.svg[`${axis}_axis`]
.attr('transform', axis_params[axis].position)
.call(this[`${axis}_axis`]);
// If necessary manually apply styles and transforms to ticks as specified by the layout
if (!ticksAreAllNumbers) {
const tick_selector = d3.selectAll(`g#${this.getBaseId().replace('.', '\\.')}\\.${axis}_axis g.tick`);
const panel = this;
tick_selector.each(function (d, i) {
const selector = d3.select(this).select('text');
if (panel[`${axis}_ticks`][i].style) {
applyStyles(selector, panel[`${axis}_ticks`][i].style);
}
if (panel[`${axis}_ticks`][i].transform) {
selector.attr('transform', panel[`${axis}_ticks`][i].transform);
}
});
}
// Render the axis label if necessary
const label = this.layout.axes[axis].label || null;
if (label !== null) {
this.svg[`${axis}_axis_label`]
.attr('x', axis_params[axis].label_x)
.attr('y', axis_params[axis].label_y)
.text(parseFields(label, this.state))
.attr('fill', 'currentColor');
if (axis_params[axis].label_rotate !== null) {
this.svg[`${axis}_axis_label`]
.attr('transform', `rotate(${axis_params[axis].label_rotate} ${axis_params[axis].label_x}, ${axis_params[axis].label_y})`);
}
}
// Attach interactive handlers to ticks as needed
['x', 'y1', 'y2'].forEach((axis) => {
if (this.layout.interaction[`drag_${axis}_ticks_to_scale`]) {
const namespace = `.${this.parent.id}.${this.id}.interaction.drag`;
const tick_mouseover = function() {
if (typeof d3.select(this).node().focus == 'function') {
d3.select(this).node().focus();
}
let cursor = (axis === 'x') ? 'ew-resize' : 'ns-resize';
if (d3.event && d3.event.shiftKey) {
cursor = 'move';
}
d3.select(this)
.style('font-weight', 'bold')
.style('cursor', cursor )
.on(`keydown${namespace}`, tick_mouseover)
.on(`keyup${namespace}`, tick_mouseover);
};
this.svg.container.selectAll(`.lz-axis.lz-${axis} .tick text`)
.attr('tabindex', 0) // necessary to make the tick focusable so keypress events can be captured
.on(`mouseover${namespace}`, tick_mouseover)
.on(`mouseout${namespace}`, function() {
d3.select(this)
.style('font-weight', 'normal')
.on(`keydown${namespace}`, null)
.on(`keyup${namespace}`, null);
})
.on(`mousedown${namespace}`, () => {
this.parent.startDrag(this, `${axis}_tick`);
});
}
});
return this;
}
/**
* Force the height of this panel to the largest absolute height of the data in
* all child data layers (if not null for any child data layers)
* @private
* @param {number|null} [target_height] A target height, which will be used in situations when the expected height can be
* pre-calculated (eg when the layers are transitioning)
*/
scaleHeightToData(target_height) {
target_height = +target_height || null;
if (target_height === null) {
this._data_layer_ids_by_z_index.forEach((id) => {
const dh = this.data_layers[id].getAbsoluteDataHeight();
if (+dh) {
if (target_height === null) {
target_height = +dh;
} else {
target_height = Math.max(target_height, +dh);
}
}
});
}
if (+target_height) {
target_height += +this.layout.margin.top + +this.layout.margin.bottom;
// FIXME: plot.setDimensions calls panel.setDimensions (though without arguments)
this.setDimensions(this.parent_plot.layout.width, target_height);
this.parent.setDimensions();
this.parent.positionPanels();
}
}
/**
* Set/unset element statuses across all data layers
* @private
* @param {String} status
* @param {Boolean} toggle
*/
setAllElementStatus(status, toggle) {
this._data_layer_ids_by_z_index.forEach((id) => {
this.data_layers[id].setAllElementStatus(status, toggle);
});
}
}
STATUSES.verbs.forEach((verb, idx) => {
const adjective = STATUSES.adjectives[idx];
const antiverb = `un${verb}`;
// Set/unset status for all elements
/**
* @private
* @function highlightAllElements
*/
/**
* @private
* @function selectAllElements
*/
/**
* @private
* @function fadeAllElements
*/
/**
* @private
* @function hideAllElements
*/
Panel.prototype[`${verb}AllElements`] = function() {
this.setAllElementStatus(adjective, true);
return this;
};
/**
* @private
* @function unhighlightAllElements
*/
/**
* @private
* @function unselectAllElements
*/
/**
* @private
* @function unfadeAllElements
*/
/**
* @private
* @function unhideAllElements
*/
Panel.prototype[`${antiverb}AllElements`] = function() {
this.setAllElementStatus(adjective, false);
return this;
};
});
export {Panel as default};