UNPKG

escher-vis

Version:

Escher: A Web Application for Building, Sharing, and Embedding Data-Rich Visualizations of Biological Pathways

570 lines (528 loc) 17.9 kB
/** * ScaleEditor. An interactive UI to edit color and size scales. * sel: A d3 selection. * type: A type, that should be unique on the page. * settings: The Settings object. */ var utils = require('./utils') var bacon = require('baconjs') var d3_scaleLinear = require('d3-scale').scaleLinear var d3_format = require('d3-format').format var d3_drag = require('d3-drag').drag var d3_select = require('d3-selection').select var d3_selection = require('d3-selection') var ScaleEditor = utils.make_class() // instance methods ScaleEditor.prototype = { init: init, update: update, update_no_data: update_no_data, _data_not_loaded: _data_not_loaded } module.exports = ScaleEditor function init (sel, type, settings, get_data_statistics) { var grad_id = 'grad' + type + this.unique_string this.w = 400 this.h = 30 this.x = 20 this.input_width = 90 this.input_height = 24 var b = sel.append('div') .attr('class', 'scale-editor') // no data loaded this.data_not_loaded = b.append('div') .attr('class', 'data-not-loaded') .text((type === 'reaction' ? 'Reaction and gene' : 'Metabolite') + ' data not loaded') // label this.input_label_group = b.append('div') .attr('class', 'input-label-group') // no data var nd = b.append('div') .style('top', this.input_height * 3 + 56 + 10 + 'px') .attr('class', 'no-data') nd.append('span').text('Styles for ' + type + 's with no data') .attr('class', 'no-data-heading') this.no_data_container = nd.append('div') var c = b.append('div') .attr('class', 'centered') this.add_group = c.append('div') this.trash_group = c.append('div') .attr('class', 'trash-container') var svg = c.append('svg') .attr('class', 'scale-svg') this.input_group = c.append('div') .attr('class', 'input-container') this.gradient = svg.append('defs') .append('linearGradient') .attr('id', grad_id) svg.append('rect') .attr('class', 'rect') .attr('fill', 'url(#' + grad_id + ')') .attr('width', this.w + 'px') .attr('height', this.h + 'px') .attr('x', this.x + 'px'), this.pickers_group = svg.append('g') // Settings this.type = type this.settings = settings this.get_data_statistics = get_data_statistics var unique_map_id = this.settings.get_option('unique_map_id') this.unique_string = (unique_map_id === null ? '' : '.' + unique_map_id) // Collect data this.no_data = {} var ss = [ 'color', 'size' ] ss.forEach(function (s) { this.no_data[s] = null this.settings.streams[this.type + '_no_data_' + s].onValue(function (val) { this.no_data[s] = val this.update_no_data() }.bind(this)) }.bind(this)) this.settings.streams[type + '_scale'].onValue(function (scale) { this.scale = scale this.update() }.bind(this)) } function update () { var scale = this.scale var stats = this.get_data_statistics()[this.type] var bar_w = 14 var bar_h = 35 var x_disp = this.x var data_not_loaded = this._data_not_loaded() // Must have max and min. Otherwise, assume that no data is loaded. if (data_not_loaded) { scale = [ { type: 'min', 'color': null, 'size': null }, { type: 'max', 'color': null, 'size': null } ] stats = { min: 0, max: 1 } } var sc = d3_scaleLinear() .domain([ 0, this.w ]) .range([ stats.min, stats.max ]) var sc_size = d3_scaleLinear() .domain([ 0, this.w ]) .range([ 0, stats.max - stats.min ]) // --------------------------------------------------------------------- // Convenience functions // --------------------------------------------------------------------- var bring_to_front = function (d, i) { // Bring an input set to the front this.input_group.selectAll('.input-set').each(function (d2) { d3_select(this).classed('selected-set', d === d2) }) }.bind(this) var get_this_val = function (d) { return (d.type === 'value') ? d.value : stats[d.type] } var set_scale = function (scale) { this.settings.set_conditional(this.type + '_scale', scale) this.scale = scale this.update() }.bind(this) // --------------------------------------------------------------------- // Make the gradient // --------------------------------------------------------------------- var sorted_domain = scale.map(function (d) { var frac = stats.max === stats.min ? (d.type === 'min' ? 0 : -1) : (get_this_val(d) - stats.min) / (stats.max - stats.min) return { frac: frac, color: d.color, } }).filter(function (d) { return (d.frac >= 0 && d.frac <= 1.0) }).sort(function (a, b) { return a.frac - b.frac }) var stops = this.gradient.selectAll('stop') .data(sorted_domain) stops.enter() .append('stop') .merge(stops) .attr('offset', function (d) { return d.frac * 100 + '%' }).style('stop-color', function (d) { return d.color === null ? '#F1ECFA' : d.color }) stops.exit().remove() // --------------------------------------------------------------------- // No data sign // --------------------------------------------------------------------- this.data_not_loaded.style('visibility', (data_not_loaded ? null : 'hidden')) // --------------------------------------------------------------------- // Make sure the pickers will not overlap // --------------------------------------------------------------------- var width = this.w var last_loc = 0 var scale_for_pickers = scale.sort(function (a, b) { return get_this_val(a) - get_this_val(b) }).map(function (d, i) { // var next = sorted_lookup[i + 1].index // make sure we distribute between these if necessary var val = get_this_val(d) var buf = bar_w + 2 var adjusted_x if (d.type === 'value' && val <= stats.min) { adjusted_x = sc.invert(stats.min) - (bar_w / 2) + x_disp - buf } else if (d.type === 'value' && val >= stats.max) { adjusted_x = sc.invert(stats.max) - (bar_w / 2) + x_disp + buf } else { adjusted_x = sc.invert(val) - (bar_w / 2) + x_disp } // Move away from edges if (d.type !== 'min' && d.type !== 'max') { adjusted_x = Math.min(Math.max(adjusted_x, last_loc + buf), width - 2) } last_loc = adjusted_x return Object.assign({}, d, { adjusted_x: adjusted_x }) }.bind(this)) // --------------------------------------------------------------------- // Make the pickers // --------------------------------------------------------------------- var pickers = this.pickers_group .selectAll('.picker') .data(scale_for_pickers) // drag var drag = d3_drag() .on('start', bring_to_front) .on('drag', function (d, i) { // on drag, make it a value type if ([ 'value', 'min', 'max' ].indexOf(scale[i].type) === -1) { // get the current value and set it scale[i].value = get_this_val(d) scale[i].type = 'value' } // change the model on drag var new_d = scale[i].value + sc_size(d3_selection.event.dx) var buf = sc_size(bar_w + 2) if (new_d > stats.max - buf) { new_d = stats.max - buf } if (new_d < stats.min + buf) { new_d = stats.min + buf } // round to 2 decimals new_d = Math.floor(new_d * 100.0) / 100.0 scale[i].value = new_d this.settings.set_conditional(this.type + '_scale', scale) this.scale = scale this.update() }.bind(this)) // enter pickers.enter() .append('g') .attr('class', 'picker') .style('cursor', 'pointer') .append('rect') // update .merge(pickers) .select('rect') .attr('x', function (d, i) { return d.adjusted_x }) .attr('width', bar_w + 'px') .attr('height', bar_h + 'px') .call(drag) // exit pickers.exit().remove() // --------------------------------------------------------------------- // make the delete buttons // --------------------------------------------------------------------- var trashes = this.trash_group.selectAll('span') .data(scale_for_pickers) // enter trashes.enter() .append('span') // update .merge(trashes) .attr('class', function (d, i) { if (d.type === 'min' || d.type === 'max') { return null } return 'trash glyphicon glyphicon-trash' }) .style('left', function (d) { // return sc.invert(get_this_val(d)) - (bar_w / 2) + x_disp + 'px' return d.adjusted_x + 'px' }) .on('click', function (d, i) { if (d.type === 'min' || d.type === 'max') { return } scale = scale.slice(0, i).concat(scale.slice(i + 1)) this.settings.set_conditional(this.type + '_scale', scale) this.scale = scale this.update() }.bind(this)) // exit trashes.exit().remove() // --------------------------------------------------------------------- // make the add button // --------------------------------------------------------------------- var add = this.add_group.selectAll('.add') .data([ 'add' ]) // enter add.enter() .append('span') .attr('class', 'add glyphicon glyphicon-plus') // update .merge(add) .on('click', function (d) { if (data_not_loaded) { return } var new_d = (stats.max + stats.min) / 2 var buf = sc_size(bar_w + 2) var last_ind = 0 // try to make the new point not overlap for (var j = 0, l = scale.length; j < l; j++) { var th = get_this_val(scale[j]) if (Math.abs(th - new_d) < buf) { new_d = new_d + buf if (new_d > stats.max - buf) new_d = stats.max - buf if (new_d < stats.min + buf) new_d = stats.min + buf } if (new_d > th) { last_ind = j } } // add scale.push({ type: 'value', value: new_d, color: scale[last_ind].color, size: scale[last_ind].size }) set_scale(scale) }.bind(this)) // exit add.exit().remove() // --------------------------------------------------------------------- // input labels // --------------------------------------------------------------------- var labels = this.input_label_group.selectAll('.row-label') .data(['Value:', 'Color:', 'Size:']) // enter labels.enter().append('div') .attr('class', 'row-label') .style('height', this.input_height + 'px') .style('line-height', this.input_height + 'px') // update .merge(labels) .style('top', function (d, i) { return 56 + (i * this.input_height) + 'px' }.bind(this)) .text(function (d) { return d }) // exit labels.exit().remove() // --------------------------------------------------------------------- // inputs // --------------------------------------------------------------------- var inputs = this.input_group.selectAll('.input-set') .data(scale_for_pickers) // enter var inputs_enter = inputs.enter() .append('div') .attr('class', 'input-set') inputs_enter.append('input') .attr('class', 'domain-input') .style('width', this.input_width + 'px') // type picker inputs_enter.append('select') .attr('class', 'domain-type-picker'), // color input inputs_enter.append('input') .attr('class', 'color-input') .style('width', this.input_width + 'px') inputs_enter.append('input') .attr('type', 'color') .style('visibility', function() { // hide the input if the HTML5 color picker is not supported return (this.type == 'text') ? 'hidden' : null }) .attr('class', 'color-picker') inputs_enter.append('input') .attr('class', 'size-input') .style('width', this.input_width + 'px') // update var inputs_update = inputs_enter.merge(inputs) inputs_update .style('height', this.input_height * 3 + 'px') .style('width', this.input_width + 'px') .style('left', function (d) { var l = d.adjusted_x // don't go over the right edge of the bar if (l + this.input_width > this.w + this.x) { l = l - this.input_width + (bar_w / 2) } return l + 'px' }.bind(this)) .on('mousedown', bring_to_front) var format = d3_format('.4g') inputs_update .select('.domain-input') .style('height', this.input_height + 'px') .each(function (d, i) { if (d.type == 'value') { this.value = get_this_val(d) this.disabled = false } else { this.value = d.type + ' (' + format(get_this_val(d)) + ')' this.disabled = true } }).on('change', function(d, i) { var buf = sc_size(bar_w + 2), new_d = parseFloat(this.value) scale[i].value = new_d set_scale(scale) }) // update type picker var select = inputs_update.select('.domain-type-picker') // get the function types, except min and max var stat_types = ([ 'value' ].concat(Object.keys(stats)) .filter(function(x) { return x !== 'min' && x !== 'max' })) var opts = select.selectAll('option').data(stat_types) opts.enter().append('option') .merge(opts) .attr('value', function (d) { return d }) .text(function (d) { return d }) opts.exit().remove() select .style('visibility', function(d) { return (d.type == 'min' || d.type == 'max') ? 'hidden' : null }) .style('left', (this.input_width - 20) + 'px') .style('width', '20px') .each(function (d, i) { var sind = 0 d3_select(this).selectAll('option').each(function(_, i) { if (this.value == d.type) sind = i }) this.selectedIndex = sind }).on('change', function(d, i) { // set the value to the current location if (this.value == 'value') scale[i].value = stats[d.type] // switch to the new type scale[i].type = this.value // reload set_scale(scale) }) // update color input inputs_update.select('.color-input') .style('height', this.input_height + 'px') .style('top', this.input_height + 'px') .each(function (d, i) { this.value = (d.color === null ? '' : d.color) this.disabled = (d.color === null) }).on('change', function(d, i) { scale[i].color = this.value set_scale(scale) }) inputs_update.select('.color-picker') .style('left', (this.input_width - this.input_height) + 'px') .style('top', this.input_height + 'px') .style('width', this.input_height + 'px') .style('height', this.input_height + 'px') .each(function (d, i) { this.value = (d.color === null ? '#dddddd' : d.color) this.disabled = (d.color === null) }) .on('change', function(d, i) { scale[i].color = this.value set_scale(scale) }) inputs_update.select('.size-input') .style('height', this.input_height + 'px') .style('top', this.input_height * 2 + 'px') .each(function (d, i) { this.value = (d.size === null ? '' : d.size) this.disabled = (d.size === null) }).on('change', function(d, i) { scale[i].size = parseFloat(this.value) set_scale(scale) }) // exit inputs.exit().remove() } function update_no_data () { var no_data = this.no_data var data_not_loaded = this._data_not_loaded() var label_w = 40 var ins = this.no_data_container .selectAll('.input-group') .data([ [ 'color', 'Color:' ], [ 'size', 'Size:' ] ]) // enter var ins_enter = ins.enter().append('div') .attr('class', 'input-group') ins_enter.append('span') ins_enter.append('input') ins_enter.append('input') .attr('type', 'color') .style('visibility', function (d) { // hide the input if the HTML5 color picker is not supported, // or if this is a size box return (this.type === 'text' || d[0] !== 'color') ? 'hidden' : null }) .attr('class', 'color-picker') // update var ins_update = ins_enter.merge(ins) ins_update.select('span') .text(function(d) { return d[1] }) .style('height', this.input_height + 'px') .style('line-height', this.input_height + 'px') .style('left', function(d, i) { return ((label_w + this.input_width + 10) * i) + 'px' }.bind(this)) var get_o = function(kind) { return this.settings.get_option(this.type + '_no_data_' + kind) }.bind(this) ins_update.select('input') .style('left', function (d, i) { return (label_w + (label_w + this.input_width + 10) * i) + 'px' }.bind(this)) .style('height', this.input_height + 'px') .style('width', this.input_width + 'px') .each(function (d) { // initial value this.value = data_not_loaded ? '' : no_data[d[0]] this.disabled = data_not_loaded }) .on('change', function (d) { var val = d3_selection.event.target.value if (d[0] === 'size') { val = parseFloat(val) } this.no_data[d[0]] = val this.settings.set_conditional(this.type + '_no_data_' + d[0], val) this.update_no_data() }.bind(this)) ins_update.select('.color-picker') .style('left', function (d, i) { return ((label_w + this.input_width) * (i + 1) - this.input_height) + 'px' }.bind(this)) .style('width', this.input_height + 'px') .style('height', this.input_height + 'px') .each(function (d, i) { this.value = data_not_loaded ? '#dddddd' : no_data[d[0]] this.disabled = data_not_loaded }) .on('change', function (d, i) { var val = d3_selection.event.target.value this.no_data[d[0]] = val this.settings.set_conditional(this.type + '_no_data_' + d[0], val) this.update_no_data() }.bind(this)) // exit ins.exit().remove() } function _data_not_loaded() { var stats = this.get_data_statistics()[this.type] return (stats.max === null || stats.min === null) }