/**
* @module
* @private
*/
import * as d3 from 'd3';
import {applyStyles} from '../helpers/common';
import {merge, nameToSymbol} from '../helpers/layouts';
// FIXME: Document legend options
/**
* The default layout used by legends (used internally)
* @protected
* @member {Object}
*/
const default_layout = {
orientation: 'vertical',
origin: { x: 0, y: 0 },
width: 10,
height: 10,
padding: 5,
label_size: 14,
hidden: false,
};
/**
* An SVG object used to display contextual information about a panel.
* Panel layouts determine basic features of a legend - its position in the panel, orientation, title, etc.
* Layouts of child data layers of the panel determine the actual content of the legend.
*
* @param {Panel} parent
*/
class Legend {
constructor(parent) {
// if (!(parent instanceof LocusZoom.Panel)) {
// throw new Error('Unable to create legend, parent must be a locuszoom panel');
// }
/** @member {Panel} */
this.parent = parent;
/** @member {String} */
this.id = `${this.parent.getBaseId()}.legend`;
this.parent.layout.legend = merge(this.parent.layout.legend || {}, default_layout);
/** @member {Object} */
this.layout = this.parent.layout.legend;
/** @member {d3.selection} */
this.selector = null;
/** @member {d3.selection} */
this.background_rect = null;
/** @member {d3.selection[]} */
this.elements = [];
/**
* SVG selector for the group containing all elements in the legend
* @protected
* @member {d3.selection|null}
*/
this.elements_group = null;
/**
* TODO: Not sure if this property is used; the external-facing methods are setting `layout.hidden` instead. Tentatively mark deprecated.
* @deprecated
* @protected
* @member {Boolean}
*/
this.hidden = false;
return this.render();
}
/**
* Render the legend in the parent panel
*/
render() {
// Get a legend group selector if not yet defined
if (!this.selector) {
this.selector = this.parent.svg.group.append('g')
.attr('id', `${this.parent.getBaseId()}.legend`).attr('class', 'lz-legend');
}
// Get a legend background rect selector if not yet defined
if (!this.background_rect) {
this.background_rect = this.selector.append('rect')
.attr('width', 100)
.attr('height', 100)
.attr('class', 'lz-legend-background');
}
// Get a legend elements group selector if not yet defined
if (!this.elements_group) {
this.elements_group = this.selector.append('g');
}
// Remove all elements from the document and re-render from scratch
this.elements.forEach((element) => element.remove());
this.elements = [];
// Gather all elements from data layers in order (top to bottom) and render them
const padding = +this.layout.padding || 1;
let x = padding;
let y = padding;
let line_height = 0;
this.parent._data_layer_ids_by_z_index.slice().reverse().forEach((id) => {
const layer_legend = this.parent.data_layers[id].layout.legend;
if (Array.isArray(layer_legend)) {
layer_legend.forEach((element) => {
const selector = this.elements_group.append('g')
.attr('transform', `translate(${x}, ${y})`);
const label_size = +element.label_size || +this.layout.label_size;
let label_x = 0;
let label_y = (label_size / 2) + (padding / 2);
line_height = Math.max(line_height, label_size + padding);
// Draw the legend element symbol (line, rect, shape, etc)
const shape = element.shape || '';
const shape_factory = nameToSymbol(shape);
if (shape === 'line') {
// Line symbol
const length = +element.length || 16;
const path_y = (label_size / 4) + (padding / 2);
selector
.append('path')
.attr('class', element.class || '')
.attr('d', `M0,${path_y}L${length},${path_y}`)
.call(applyStyles, element.style || {});
label_x = length + padding;
} else if (shape === 'rect') {
// Rect symbol
const width = +element.width || 16;
const height = +element.height || width;
selector
.append('rect')
.attr('class', element.class || '')
.attr('width', width)
.attr('height', height)
.attr('fill', element.color || {})
.call(applyStyles, element.style || {});
label_x = width + padding;
line_height = Math.max(line_height, height + padding);
} else if (shape === 'ribbon') {
// Color ribbons describe a series of color stops: small boxes of color across a continuous
// scale. Drawn horizontally, or vertically, like:
// [red | orange | yellow | green ] label
// For example, this can be used with the numerical-bin color scale to describe LD color stops in a compact way.
const width = +element.width || 25;
const height = +element.height || width;
const is_horizontal = (element.orientation || 'vertical') === 'horizontal';
let color_stops = element.color_stops;
const all_elements = selector.append('g');
const ribbon_group = all_elements.append('g');
const axis_group = all_elements.append('g');
let axis_offset = 0;
if (element.tick_labels) {
let range;
if (is_horizontal) {
range = [0, width * color_stops.length - 1]; // 1 px offset to align tick with inner borders
} else {
range = [height * color_stops.length - 1, 0];
}
const scale = d3.scaleLinear()
.domain(d3.extent(element.tick_labels)) // Assumes tick labels are always numeric in this mode
.range(range);
const axis = (is_horizontal ? d3.axisTop : d3.axisRight)(scale)
.tickSize(3)
.tickValues(element.tick_labels)
.tickFormat((v) => v);
axis_group
.call(axis)
.attr('class', 'lz-axis');
let bcr = axis_group.node().getBoundingClientRect();
axis_offset = bcr.height;
}
if (is_horizontal) {
// Shift axis down (so that tick marks aren't above the origin)
axis_group
.attr('transform', `translate(0, ${axis_offset})`);
// Ribbon appears below axis
ribbon_group
.attr('transform', `translate(0, ${axis_offset})`);
} else {
// Vertical mode: Shift axis ticks to the right of the ribbon
all_elements.attr('transform', 'translate(5, 0)');
axis_group
.attr('transform', `translate(${width}, 0)`);
}
if (!is_horizontal) {
// Vertical mode: renders top -> bottom but scale is usually specified low..high
color_stops = color_stops.slice();
color_stops.reverse();
}
for (let i = 0; i < color_stops.length; i++) {
const color = color_stops[i];
const to_next_marking = is_horizontal ? `translate(${width * i}, 0)` : `translate(0, ${height * i})`;
ribbon_group
.append('rect')
.attr('class', element.class || '')
.attr('stroke', 'black')
.attr('transform', to_next_marking)
.attr('stroke-width', 0.5)
.attr('width', width)
.attr('height', height)
.attr('fill', color)
.call(applyStyles, element.style || {});
}
// Note: In vertical mode, it's usually easier to put the label above the legend as a separate marker
// This is because the legend element label is drawn last (can't use it's size to position the ribbon, which is drawn first)
if (!is_horizontal && element.label) {
throw new Error('Legend labels not supported for vertical ribbons (use a separate legend item as text instead)');
}
// This only makes sense for horizontal labels.
label_x = (width * color_stops.length + padding);
label_y += axis_offset;
} else if (shape_factory) {
// Shape symbol is a recognized d3 type, so we can draw it in the legend (circle, diamond, etc.)
const size = +element.size || 40;
const radius = Math.ceil(Math.sqrt(size / Math.PI));
selector
.append('path')
.attr('class', element.class || '')
.attr('d', d3.symbol().size(size).type(shape_factory))
.attr('transform', `translate(${radius}, ${radius + (padding / 2)})`)
.attr('fill', element.color || {})
.call(applyStyles, element.style || {});
label_x = (2 * radius) + padding;
label_y = Math.max((2 * radius) + (padding / 2), label_y);
line_height = Math.max(line_height, (2 * radius) + padding);
}
// Draw the legend element label
selector
.append('text')
.attr('text-anchor', 'left')
.attr('class', 'lz-label')
.attr('x', label_x)
.attr('y', label_y)
.style('font-size', label_size)
.text(element.label);
// Position the legend element group based on legend layout orientation
const bcr = selector.node().getBoundingClientRect();
if (this.layout.orientation === 'vertical') {
y += bcr.height + padding;
line_height = 0;
} else {
// Ensure this element does not exceed the panel width
// (E.g. drop to the next line if it does, but only if it's not the only element on this line)
const right_x = this.layout.origin.x + x + bcr.width;
if (x > padding && right_x > this.parent.parent.layout.width) {
y += line_height;
x = padding;
selector.attr('transform', `translate(${x}, ${y})`);
}
x += bcr.width + (3 * padding);
}
// Store the element
this.elements.push(selector);
});
}
});
// Scale the background rect to the elements in the legend
const bcr = this.elements_group.node().getBoundingClientRect();
this.layout.width = bcr.width + (2 * this.layout.padding);
this.layout.height = bcr.height + (2 * this.layout.padding);
this.background_rect
.attr('width', this.layout.width)
.attr('height', this.layout.height);
// Set the visibility on the legend from the "hidden" flag
// TODO: `show()` and `hide()` call a full rerender; might be able to make this more lightweight?
this.selector
.style('visibility', this.layout.hidden ? 'hidden' : 'visible');
return this.position();
}
/**
* Place the legend in position relative to the panel, as specified in the layout configuration
* @returns {Legend | null}
* TODO: should this always be chainable?
*/
position() {
if (!this.selector) {
return this;
}
const bcr = this.selector.node().getBoundingClientRect();
if (!isNaN(+this.layout.pad_from_bottom)) {
this.layout.origin.y = this.parent.layout.height - bcr.height - +this.layout.pad_from_bottom;
}
if (!isNaN(+this.layout.pad_from_right)) {
this.layout.origin.x = this.parent.parent.layout.width - bcr.width - +this.layout.pad_from_right;
}
this.selector.attr('transform', `translate(${this.layout.origin.x}, ${this.layout.origin.y})`);
}
/**
* Hide the legend (triggers a re-render)
* @public
*/
hide() {
this.layout.hidden = true;
this.render();
}
/**
* Show the legend (triggers a re-render)
* @public
*/
show() {
this.layout.hidden = false;
this.render();
}
}
export {Legend as default};