Source: components/data_layer/genes.js

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};