import * as d3 from 'd3';
import BaseDataLayer from './base';
import {merge} from '../../helpers/layouts';
/**
* @memberof module:LocusZoom_DataLayers~genes
* @type {{track_vertical_spacing: number, bounding_box_padding: number, color: string, tooltip_positioning: string, exon_height: number, label_font_size: number, label_exon_spacing: number, stroke: string}}
*/
const default_layout = {
// Optionally specify different fill and stroke properties
stroke: 'rgb(54, 54, 150)',
color: '#363696',
label_font_size: 15,
label_exon_spacing: 3,
exon_height: 10,
bounding_box_padding: 3,
track_vertical_spacing: 5,
tooltip_positioning: 'top',
};
/**
* Genes Data Layer
* Implements a data layer that will render gene tracks
* @alias module:LocusZoom_DataLayers~genes
* @see {@link module:LocusZoom_DataLayers~BaseDataLayer} for additional layout options
*/
class Genes extends BaseDataLayer {
/**
* @param {string|module:LocusZoom_DataLayers~ScalableParameter[]} [layout.stroke='rgb(54, 54, 150)'] The stroke color for each intron and exon
* @param {string|module:LocusZoom_DataLayers~ScalableParameter[]} [layout.color='#363696'] The fill color for each intron and exon
* @param {number} [layout.label_font_size]
* @param {number} [layout.label_exon_spacing] The number of px padding between exons and the gene label
* @param {number} [layout.exon_height=10] The height of each exon (vertical line) when drawing the gene
* @param {number} [layout.bounding_box_padding=3] Padding around edges of the bounding box, as shown when highlighting a selected gene
* @param {number} [layout.track_vertical_spacing=5] Vertical spacing between each row of genes
* @param {'horizontal'|'vertical'|'top'|'bottom'|'left'|'right'} [layout.tooltip_positioning='top'] Where to draw the tooltip relative to the datum.
*/
constructor(layout) {
layout = merge(layout, default_layout);
super(...arguments);
/**
* A gene may have arbitrarily many transcripts, but this data layer isn't set up to render them yet.
* Stash a transcript_idx to point to the first transcript and use that for all transcript refs.
* @member {number}
* @type {number}
*/
this.transcript_idx = 0;
/**
* An internal counter for the number of tracks in the data layer. Used as an internal counter for looping
* over positions / assignments
* @protected
* @member {number}
*/
this.tracks = 1;
/**
* Store information about genes in dataset, in a hash indexed by track number: {track_number: [gene_indices]}
* @member {Object.<Number, Array>}
*/
this.gene_track_index = { 1: [] };
}
/**
* Generate a statusnode ID for a given element
* @override
* @returns {String}
*/
getElementStatusNodeId(element) {
return `${this.getElementId(element)}-statusnode`;
}
/**
* Helper function to sum layout values to derive total height for a single gene track
* @returns {number}
*/
getTrackHeight() {
return 2 * this.layout.bounding_box_padding
+ this.layout.label_font_size
+ this.layout.label_exon_spacing
+ this.layout.exon_height
+ this.layout.track_vertical_spacing;
}
/**
* Ensure that genes in overlapping chromosome regions are positioned so that parts of different genes do not
* overlap in the view. A track is a row used to vertically separate overlapping genes.
* @returns {Genes}
*/
assignTracks(data) {
/**
* Function to get the width in pixels of a label given the text and layout attributes
* @param {String} gene_name
* @param {number|string} font_size
* @returns {number}
*/
const _getLabelWidth = (gene_name, font_size) => {
try {
const temp_text = this.svg.group.append('text')
.attr('x', 0)
.attr('y', 0)
.attr('class', 'lz-data_layer-genes lz-label')
.style('font-size', font_size)
.text(`${gene_name}→`);
const label_width = temp_text.node().getBBox().width;
temp_text.remove();
return label_width;
} catch (e) {
return 0;
}
};
// Reinitialize some metadata
this.tracks = 1;
this.gene_track_index = { 1: [] };
return data
// Filter out any genes that are fully outside the region of interest. This allows us to use cached data
// when zooming in, without breaking the layout by allocating space for genes that are not visible.
.filter((item) => !(item.end < this.state.start) && !(item.start > this.state.end))
.map((item) => {
// If necessary, split combined gene id / version fields into discrete fields.
// NOTE: this may be an issue with CSG's genes data API that may eventually be solved upstream.
if (item.gene_id && item.gene_id.indexOf('.')) {
const split = item.gene_id.split('.');
item.gene_id = split[0];
item.gene_version = split[1];
}
// Stash the transcript ID on the parent gene
item.transcript_id = item.transcripts[this.transcript_idx].transcript_id;
// Determine display range start and end, based on minimum allowable gene display width, bounded by what we can see
// (range: values in terms of pixels on the screen)
item.display_range = {
start: this.parent.x_scale(Math.max(item.start, this.state.start)),
end: this.parent.x_scale(Math.min(item.end, this.state.end)),
};
item.display_range.label_width = _getLabelWidth(item.gene_name, this.layout.label_font_size);
item.display_range.width = item.display_range.end - item.display_range.start;
// Determine label text anchor (default to middle)
item.display_range.text_anchor = 'middle';
if (item.display_range.width < item.display_range.label_width) {
if (item.start < this.state.start) {
item.display_range.end = item.display_range.start
+ item.display_range.label_width
+ this.layout.label_font_size;
item.display_range.text_anchor = 'start';
} else if (item.end > this.state.end) {
item.display_range.start = item.display_range.end
- item.display_range.label_width
- this.layout.label_font_size;
item.display_range.text_anchor = 'end';
} else {
const centered_margin = ((item.display_range.label_width - item.display_range.width) / 2)
+ this.layout.label_font_size;
if ((item.display_range.start - centered_margin) < this.parent.x_scale(this.state.start)) {
item.display_range.start = this.parent.x_scale(this.state.start);
item.display_range.end = item.display_range.start + item.display_range.label_width;
item.display_range.text_anchor = 'start';
} else if ((item.display_range.end + centered_margin) > this.parent.x_scale(this.state.end)) {
item.display_range.end = this.parent.x_scale(this.state.end);
item.display_range.start = item.display_range.end - item.display_range.label_width;
item.display_range.text_anchor = 'end';
} else {
item.display_range.start -= centered_margin;
item.display_range.end += centered_margin;
}
}
item.display_range.width = item.display_range.end - item.display_range.start;
}
// Add bounding box padding to the calculated display range start, end, and width
item.display_range.start -= this.layout.bounding_box_padding;
item.display_range.end += this.layout.bounding_box_padding;
item.display_range.width += 2 * this.layout.bounding_box_padding;
// Convert and stash display range values into domain values
// (domain: values in terms of the data set, e.g. megabases)
item.display_domain = {
start: this.parent.x_scale.invert(item.display_range.start),
end: this.parent.x_scale.invert(item.display_range.end),
};
item.display_domain.width = item.display_domain.end - item.display_domain.start;
// Using display range/domain data generated above cast each gene to tracks such that none overlap
item.track = null;
let potential_track = 1;
while (item.track === null) {
let collision_on_potential_track = false;
this.gene_track_index[potential_track].map((placed_gene) => {
if (!collision_on_potential_track) {
const min_start = Math.min(placed_gene.display_range.start, item.display_range.start);
const max_end = Math.max(placed_gene.display_range.end, item.display_range.end);
if ((max_end - min_start) < (placed_gene.display_range.width + item.display_range.width)) {
collision_on_potential_track = true;
}
}
});
if (!collision_on_potential_track) {
item.track = potential_track;
this.gene_track_index[potential_track].push(item);
} else {
potential_track++;
if (potential_track > this.tracks) {
this.tracks = potential_track;
this.gene_track_index[potential_track] = [];
}
}
}
// Stash parent references on all genes, transcripts, and exons
item.parent = this;
item.transcripts.map((d, t) => {
item.transcripts[t].parent = item;
item.transcripts[t].exons.map((d, e) => item.transcripts[t].exons[e].parent = item.transcripts[t]);
});
return item;
});
}
/**
* Main render function
*/
render() {
const self = this;
// Apply filters to only render a specified set of points
let track_data = this._applyFilters();
track_data = this.assignTracks(track_data);
let height;
// Render gene groups
const selection = this.svg.group.selectAll('g.lz-data_layer-genes')
.data(track_data, (d) => d.gene_name);
selection.enter()
.append('g')
.attr('class', 'lz-data_layer-genes')
.merge(selection)
.attr('id', (d) => this.getElementId(d))
.each(function(gene) {
const data_layer = gene.parent;
// Render gene bounding boxes (status nodes to show selected/highlighted)
const bboxes = d3.select(this).selectAll('rect.lz-data_layer-genes.lz-data_layer-genes-statusnode')
.data([gene], (d) => data_layer.getElementStatusNodeId(d));
height = data_layer.getTrackHeight() - data_layer.layout.track_vertical_spacing;
bboxes.enter()
.append('rect')
.attr('class', 'lz-data_layer-genes lz-data_layer-genes-statusnode')
.merge(bboxes)
.attr('id', (d) => data_layer.getElementStatusNodeId(d))
.attr('rx', data_layer.layout.bounding_box_padding)
.attr('ry', data_layer.layout.bounding_box_padding)
.attr('width', (d) => d.display_range.width)
.attr('height', height)
.attr('x', (d) => d.display_range.start)
.attr('y', (d) => ((d.track - 1) * data_layer.getTrackHeight()));
bboxes.exit()
.remove();
// Render gene boundaries
const boundaries = d3.select(this).selectAll('rect.lz-data_layer-genes.lz-boundary')
.data([gene], (d) => `${d.gene_name}_boundary`);
// FIXME: Make gene text font sizes scalable
height = 1;
boundaries.enter()
.append('rect')
.attr('class', 'lz-data_layer-genes lz-boundary')
.merge(boundaries)
.attr('width', (d) => data_layer.parent.x_scale(d.end) - data_layer.parent.x_scale(d.start))
.attr('height', height)
.attr('x', (d) => data_layer.parent.x_scale(d.start))
.attr('y', (d) => {
return ((d.track - 1) * data_layer.getTrackHeight())
+ data_layer.layout.bounding_box_padding
+ data_layer.layout.label_font_size
+ data_layer.layout.label_exon_spacing
+ (Math.max(data_layer.layout.exon_height, 3) / 2);
})
.style('fill', (d, i) => self.resolveScalableParameter(self.layout.color, d, i))
.style('stroke', (d, i) => self.resolveScalableParameter(self.layout.stroke, d, i));
boundaries.exit()
.remove();
// Render gene labels
const labels = d3.select(this).selectAll('text.lz-data_layer-genes.lz-label')
.data([gene], (d) => `${d.gene_name}_label`);
labels.enter()
.append('text')
.attr('class', 'lz-data_layer-genes lz-label')
.merge(labels)
.attr('text-anchor', (d) => d.display_range.text_anchor)
.text((d) => (d.strand === '+') ? `${d.gene_name}→` : `←${d.gene_name}`)
.style('font-size', gene.parent.layout.label_font_size)
.attr('x', (d) => {
if (d.display_range.text_anchor === 'middle') {
return d.display_range.start + (d.display_range.width / 2);
} else if (d.display_range.text_anchor === 'start') {
return d.display_range.start + data_layer.layout.bounding_box_padding;
} else if (d.display_range.text_anchor === 'end') {
return d.display_range.end - data_layer.layout.bounding_box_padding;
}
})
.attr('y', (d) => ((d.track - 1) * data_layer.getTrackHeight())
+ data_layer.layout.bounding_box_padding
+ data_layer.layout.label_font_size,
);
labels.exit()
.remove();
// Render exon rects (first transcript only, for now)
// Exons: by default color on gene properties for consistency with the gene boundary track- hence color uses d.parent.parent
const exons = d3.select(this).selectAll('rect.lz-data_layer-genes.lz-exon')
.data(gene.transcripts[gene.parent.transcript_idx].exons, (d) => d.exon_id);
height = data_layer.layout.exon_height;
exons.enter()
.append('rect')
.attr('class', 'lz-data_layer-genes lz-exon')
.merge(exons)
.style('fill', (d, i) => self.resolveScalableParameter(self.layout.color, d.parent.parent, i))
.style('stroke', (d, i) => self.resolveScalableParameter(self.layout.stroke, d.parent.parent, i))
.attr('width', (d) => data_layer.parent.x_scale(d.end) - data_layer.parent.x_scale(d.start))
.attr('height', height)
.attr('x', (d) => data_layer.parent.x_scale(d.start))
.attr('y', () => {
return ((gene.track - 1) * data_layer.getTrackHeight())
+ data_layer.layout.bounding_box_padding
+ data_layer.layout.label_font_size
+ data_layer.layout.label_exon_spacing;
});
exons.exit()
.remove();
// Render gene click area
const clickareas = d3.select(this).selectAll('rect.lz-data_layer-genes.lz-clickarea')
.data([gene], (d) => `${d.gene_name}_clickarea`);
height = data_layer.getTrackHeight() - data_layer.layout.track_vertical_spacing;
clickareas.enter()
.append('rect')
.attr('class', 'lz-data_layer-genes lz-clickarea')
.merge(clickareas)
.attr('id', (d) => `${data_layer.getElementId(d)}_clickarea`)
.attr('rx', data_layer.layout.bounding_box_padding)
.attr('ry', data_layer.layout.bounding_box_padding)
.attr('width', (d) => d.display_range.width)
.attr('height', height)
.attr('x', (d) => d.display_range.start)
.attr('y', (d) => ((d.track - 1) * data_layer.getTrackHeight()));
// Remove old clickareas as needed
clickareas.exit()
.remove();
});
// Remove old elements as needed
selection.exit()
.remove();
// Apply mouse behaviors & events to clickareas
this.svg.group
.on('click.event_emitter', (element) => this.parent.emit('element_clicked', element, true))
.call(this.applyBehaviors.bind(this));
}
_getTooltipPosition(tooltip) {
const gene_bbox_id = this.getElementStatusNodeId(tooltip.data);
const gene_bbox = d3.select(`#${gene_bbox_id}`).node().getBBox();
return {
x_min: this.parent.x_scale(tooltip.data.start),
x_max: this.parent.x_scale(tooltip.data.end),
y_min: gene_bbox.y,
y_max: gene_bbox.y + gene_bbox.height,
};
}
}
export {Genes as default};