UNPKG

escher-vis

Version:

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

1,534 lines (1,330 loc) 80 kB
/** Map Defines the metabolic map data, and manages drawing and building. Arguments --------- svg: The parent SVG container for the map. css: selection: A d3 selection for a node to place the map inside. selection: zoom_container: settings: cobra_model: canvas_size_and_loc: enable_search: map_name: (Optional, Default: 'new map') map_id: (Optional, Default: A string of random characters.) map_description: (Optional, Default: '') Callbacks --------- map.callback_manager.run('set_status', null, status) map.callback_manager.run('toggle_beziers', null, beziers_enabled) map.callback_manager.run('select_metabolite_with_id', null, selected_node, coords) map.callback_manager.run('select_selectable', null, node_count, selected_node, coords) map.callback_manager.run('deselect_nodes') map.callback_manager.run('select_text_label') map.callback_manager.run('before_svg_export') map.callback_manager.run('after_svg_export') map.callback_manager.run('before_png_export') map.callback_manager.run('after_png_export') map.callback_manager.run('before_convert_map') map.callback_manager.run('after_convert_map') this.callback_manager.run('calc_data_stats__reaction', null, changed) this.callback_manager.run('calc_data_stats__metabolite', null, changed) */ var utils = require('./utils') var Draw = require('./Draw') var Behavior = require('./Behavior') var Scale = require('./Scale') var build = require('./build') var UndoStack = require('./UndoStack') var CallbackManager = require('./CallbackManager') var KeyManager = require('./KeyManager') var Canvas = require('./Canvas') var data_styles = require('./data_styles') var SearchIndex = require('./SearchIndex') var bacon = require('baconjs') var _ = require('underscore') var d3_select = require('d3-selection').select var Map = utils.make_class() // class methods Map.from_data = from_data // instance methods Map.prototype = { // setup init: init, // more setup setup_containers: setup_containers, reset_containers: reset_containers, // appearance set_status: set_status, clear_map: clear_map, // selection select_all: select_all, select_none: select_none, invert_selection: invert_selection, select_selectable: select_selectable, select_metabolite_with_id: select_metabolite_with_id, select_single_node: select_single_node, deselect_nodes: deselect_nodes, select_text_label: select_text_label, deselect_text_labels: deselect_text_labels, // build new_reaction_from_scratch: new_reaction_from_scratch, extend_nodes: extend_nodes, extend_reactions: extend_reactions, new_reaction_for_metabolite: new_reaction_for_metabolite, cycle_primary_node: cycle_primary_node, toggle_selected_node_primary: toggle_selected_node_primary, add_label_to_search_index: add_label_to_search_index, new_text_label: new_text_label, edit_text_label: edit_text_label, // delete delete_selected: delete_selected, delete_selectable: delete_selectable, delete_node_data: delete_node_data, delete_segment_data: delete_segment_data, delete_reaction_data: delete_reaction_data, delete_text_label_data: delete_text_label_data, // find get_selected_node_ids: get_selected_node_ids, get_selected_nodes: get_selected_nodes, get_selected_text_label_ids: get_selected_text_label_ids, get_selected_text_labels: get_selected_text_labels, segments_and_reactions_for_nodes: segments_and_reactions_for_nodes, // draw draw_everything: draw_everything, // draw reactions draw_all_reactions: draw_all_reactions, draw_these_reactions: draw_these_reactions, clear_deleted_reactions: clear_deleted_reactions, // draw nodes draw_all_nodes: draw_all_nodes, draw_these_nodes: draw_these_nodes, clear_deleted_nodes: clear_deleted_nodes, // draw text_labels draw_all_text_labels: draw_all_text_labels, draw_these_text_labels: draw_these_text_labels, clear_deleted_text_labels: clear_deleted_text_labels, // draw beziers draw_all_beziers: draw_all_beziers, draw_these_beziers: draw_these_beziers, clear_deleted_beziers: clear_deleted_beziers, toggle_beziers: toggle_beziers, hide_beziers: hide_beziers, show_beziers: show_beziers, // data has_cobra_model: has_cobra_model, apply_reaction_data_to_map: apply_reaction_data_to_map, apply_metabolite_data_to_map: apply_metabolite_data_to_map, apply_gene_data_to_map: apply_gene_data_to_map, // data statistics get_data_statistics: get_data_statistics, calc_data_stats: calc_data_stats, // zoom zoom_extent_nodes: zoom_extent_nodes, zoom_extent_canvas: zoom_extent_canvas, _zoom_extent: _zoom_extent, get_size: get_size, zoom_to_reaction: zoom_to_reaction, zoom_to_node: zoom_to_node, zoom_to_text_label: zoom_to_text_label, highlight_reaction: highlight_reaction, highlight_node: highlight_node, highlight_text_label: highlight_text_label, highlight: highlight, // full screen listen_for_full_screen: listen_for_full_screen, unlisten_for_full_screen: unlisten_for_full_screen, full_screen: full_screen, // io save: save, map_for_export: map_for_export, save_svg: save_svg, save_png: save_png, convert_map: convert_map } module.exports = Map // ------------------------------------------------------------------------- // Setup // ------------------------------------------------------------------------- function init (svg, css, selection, zoom_container, settings, cobra_model, canvas_size_and_loc, enable_search, map_name, map_id, map_description) { if (canvas_size_and_loc === null) { var size = zoom_container.get_size() canvas_size_and_loc = { x: -size.width, y: -size.height, width: size.width*3, height: size.height*3 } } if (_.isUndefined(map_name) || map_name === null || map_name === '') { map_name = 'new_map' } else { map_name = String(map_name) } if (_.isUndefined(map_id) || map_id === null || map_id === '') { map_id = utils.generate_map_id() } else { map_id = String(map_id) } if (_.isUndefined(map_description) || map_description === null) { map_description = '' } else { map_description = String(map_description) } // set up the callbacks this.callback_manager = new CallbackManager() // set up the defs this.svg = svg this.defs = utils.setup_defs(svg, css) // make the canvas this.canvas = new Canvas(selection, canvas_size_and_loc) this.setup_containers(selection) this.sel = selection this.zoom_container = zoom_container this.settings = settings // set the model AFTER loading the datasets this.cobra_model = cobra_model this.largest_ids = { reactions: -1, nodes: -1, segments: -1, text_labels: -1 } // make the scales this.scale = new Scale() // initialize stats this.calc_data_stats('reaction') this.calc_data_stats('metabolite') this.scale.connect_to_settings(this.settings, this, get_data_statistics.bind(this)) // make the undo/redo stack this.undo_stack = new UndoStack() // make a behavior object this.behavior = new Behavior(this, this.undo_stack) // draw manager this.draw = new Draw(this.behavior, this.settings) // make a key manager this.key_manager = new KeyManager() this.key_manager.ctrl_equals_cmd = true // make the search index this.enable_search = enable_search this.search_index = new SearchIndex() // map properties this.map_name = map_name this.map_id = map_id this.map_description = map_description // deal with the window var window_translate = { x: 0, y: 0 } var window_scale = 1 // hide beziers this.beziers_enabled = false // data this.has_data_on_reactions = false this.has_data_on_nodes = false this.imported_reaction_data = null this.imported_metabolite_data = null this.imported_gene_data = null this.nodes = {} this.reactions = {} this.beziers = {} this.text_labels = {} // update data with null to populate data-specific attributes this.apply_reaction_data_to_map(null) this.apply_metabolite_data_to_map(null) this.apply_gene_data_to_map(null) // rotation mode off this.rotation_on = false // set up full screen listener this.listen_for_full_screen(function () { setTimeout(function() { this.zoom_extent_canvas() }.bind(this), 50) }.bind(this)) } // ------------------------------------------------------------------------- // Import // ------------------------------------------------------------------------- /** * Load a json map and add necessary fields for rendering. */ function from_data (map_data, svg, css, selection, zoom_container, settings, cobra_model, enable_search) { var canvas = map_data[1].canvas var map_name = map_data[0].map_name var map_id = map_data[0].map_id var map_description = (map_data[0].map_description.replace(/(\nLast Modified.*)+$/g, '') + '\nLast Modified ' + Date(Date.now()).toString()) var map = new Map(svg, css, selection, zoom_container, settings, cobra_model, canvas, enable_search, map_name, map_id, map_description) map.reactions = map_data[1].reactions map.nodes = map_data[1].nodes map.text_labels = map_data[1].text_labels for (var n_id in map.nodes) { var node = map.nodes[n_id] // clear all the connected segments node.connected_segments = [] // populate the nodes search index. if (enable_search) { if (node.node_type !== 'metabolite') continue map.search_index.insert('n' + n_id, { name: node.bigg_id, data: { type: 'metabolite', node_id: n_id }}) map.search_index.insert('n_name' + n_id, { name: node.name, data: { type: 'metabolite', node_id: n_id }}) } } // Propagate coefficients and reversibility, build the connected // segments, add bezier points, and populate the reaction search index. for (var r_id in map.reactions) { var reaction = map.reactions[r_id] // reaction search index if (enable_search) { map.search_index.insert('r' + r_id, { 'name': reaction.bigg_id, 'data': { type: 'reaction', reaction_id: r_id }}) map.search_index.insert('r_name' + r_id, { 'name': reaction.name, 'data': { type: 'reaction', reaction_id: r_id }}) for (var g_id in reaction.genes) { var gene = reaction.genes[g_id] map.search_index.insert('r' + r_id + '_g' + g_id, { 'name': gene.bigg_id, 'data': { type: 'reaction', reaction_id: r_id }}) map.search_index.insert('r' + r_id + '_g_name' + g_id, { 'name': gene.name, 'data': { type: 'reaction', reaction_id: r_id }}) } } // keep track of any bad segments var segments_to_delete = [] for (var s_id in reaction.segments) { var segment = reaction.segments[s_id] // propagate reversibility segment.reversibility = reaction.reversibility // if there is an error with to_ or from_ nodes, remove this segment if (!(segment.from_node_id in map.nodes) || !(segment.to_node_id in map.nodes)) { console.warn('Bad node references in segment ' + s_id + '. Deleting segment.') segments_to_delete.push(s_id) continue } var from_node = map.nodes[segment.from_node_id], to_node = map.nodes[segment.to_node_id] // propagate coefficients reaction.metabolites.forEach(function(met) { if (met.bigg_id==from_node.bigg_id) { segment.from_node_coefficient = met.coefficient } else if (met.bigg_id==to_node.bigg_id) { segment.to_node_coefficient = met.coefficient } }) // build connected segments ;[from_node, to_node].forEach(function(node) { node.connected_segments.push({ segment_id: s_id, reaction_id: r_id }) }) // If the metabolite has no bezier points, then add them. var start = map.nodes[segment.from_node_id], end = map.nodes[segment.to_node_id] if (start['node_type']=='metabolite' || end['node_type']=='metabolite') { var midpoint = utils.c_plus_c(start, utils.c_times_scalar(utils.c_minus_c(end, start), 0.5)) if (segment.b1 === null) segment.b1 = midpoint if (segment.b2 === null) segment.b2 = midpoint } } // delete the bad segments segments_to_delete.forEach(function(s_id) { delete reaction.segments[s_id] }) } // add text_labels to the search index if (enable_search) { for (var label_id in map.text_labels) { var label = map.text_labels[label_id] map.search_index.insert('l'+label_id, { 'name': label.text, 'data': { type: 'text_label', text_label_id: label_id }}) } } // populate the beziers map.beziers = build.new_beziers_for_reactions(map.reactions) // get largest ids for adding new reactions, nodes, text labels, and // segments map.largest_ids.reactions = get_largest_id(map.reactions) map.largest_ids.nodes = get_largest_id(map.nodes) map.largest_ids.text_labels = get_largest_id(map.text_labels) var largest_segment_id = 0 for (var id in map.reactions) { largest_segment_id = get_largest_id(map.reactions[id].segments, largest_segment_id) } map.largest_ids.segments = largest_segment_id // update data with null to populate data-specific attributes map.apply_reaction_data_to_map(null) map.apply_metabolite_data_to_map(null) map.apply_gene_data_to_map(null) return map /** * Return the largest integer key in obj, or current_largest, whichever is * bigger. */ function get_largest_id (obj, current_largest) { if (_.isUndefined(current_largest)) current_largest = 0 if (_.isUndefined(obj)) return current_largest return Math.max.apply(null, Object.keys(obj).map(function(x) { return parseInt(x) }).concat([current_largest])) } } // --------------------------------------------------------------------- // more setup function setup_containers(sel) { sel.append('g') .attr('id', 'reactions') sel.append('g') .attr('id', 'nodes') sel.append('g') .attr('id', 'beziers') sel.append('g') .attr('id', 'text-labels') } function reset_containers() { this.sel.select('#reactions') .selectAll('.reaction') .remove() this.sel.select('#nodes') .selectAll('.node') .remove() this.sel.select('#beziers') .selectAll('.bezier') .remove() this.sel.select('#text-labels') .selectAll('.text-label') .remove() } // ------------------------------------------------------------------------- // Appearance // ------------------------------------------------------------------------- function set_status (status, time) { /** Set the status of the map, with an optional expiration time. Rendering the status is taken care of by the Builder. Arguments --------- status: The status string. time: An optional time, in ms, after which the status is set to ''. */ this.callback_manager.run('set_status', null, status) // clear any other timers on the status bar clearTimeout(this._status_timer) this._status_timer = null if (time!==undefined) { this._status_timer = setTimeout(function() { this.callback_manager.run('set_status', null, '') }.bind(this), time) } } function clear_map() { this.reactions = {} this.beziers = {} this.nodes = {} this.text_labels = {} this.map_name = 'new_map' this.map_id = utils.generate_map_id() this.map_description = '' // reaction_data onto existing map reactions this.apply_reaction_data_to_map(null) this.apply_metabolite_data_to_map(null) this.apply_gene_data_to_map(null) this.draw_everything() } function has_cobra_model() { return (this.cobra_model !== null) } function draw_everything() { /** Draw the all reactions, nodes, & text labels. */ this.draw_all_reactions(true, true); // also draw beziers this.draw_all_nodes(true) this.draw_all_text_labels() } function draw_all_reactions(draw_beziers, clear_deleted) { /** Draw all reactions, and clear deleted reactions. Arguments --------- draw_beziers: (Boolean, default True) Whether to also draw the bezier control points. clear_deleted: (Optional, Default: true) Boolean, if true, then also clear deleted nodes. */ if (_.isUndefined(draw_beziers)) draw_beziers = true if (_.isUndefined(clear_deleted)) clear_deleted = true // Draw all reactions. var reaction_ids = [] for (var reaction_id in this.reactions) { reaction_ids.push(reaction_id) } // If draw_beziers is true, just draw them all, rather than deciding // which ones to draw. this.draw_these_reactions(reaction_ids, false) if (draw_beziers && this.beziers_enabled) this.draw_all_beziers() // Clear all deleted reactions. if (clear_deleted) this.clear_deleted_reactions(draw_beziers) } /** * Draw specific reactions. Does nothing with exit selection. Use * clear_deleted_reactions to remove reactions from the DOM. * reactions_ids: An array of reaction_ids to update. * draw_beziers: (Boolean, default True) Whether to also draw the bezier control * points. */ function draw_these_reactions (reaction_ids, draw_beziers) { if (_.isUndefined(draw_beziers)) draw_beziers = true // find reactions for reaction_ids var reaction_subset = utils.object_slice_for_ids_ref(this.reactions, reaction_ids) // function to update reactions var update_fn = function(sel) { return this.draw.update_reaction(sel, this.scale, this.cobra_model, this.nodes, this.defs, this.has_data_on_reactions) }.bind(this) // draw the reactions utils.draw_an_object(this.sel, '#reactions', '.reaction', reaction_subset, 'reaction_id', this.draw.create_reaction.bind(this.draw), update_fn) if (draw_beziers) { // particular beziers to draw var bezier_ids = build.bezier_ids_for_reaction_ids(reaction_subset) this.draw_these_beziers(bezier_ids) } } /** * Remove any reactions that are not in *this.reactions*. * draw_beziers: (Boolean, default True) Whether to also clear deleted bezier * control points. */ function clear_deleted_reactions (draw_beziers) { if (_.isUndefined(draw_beziers)) draw_beziers = true // Remove deleted reactions and segments utils.draw_an_object( this.sel, '#reactions', '.reaction', this.reactions, 'reaction_id', null, function (update_selection) { // Draw segments utils.draw_a_nested_object( update_selection, '.segment-group', 'segments', 'segment_id', null, null, function(sel) { sel.remove() } ) }, function (sel) { sel.remove() } ) if (draw_beziers === true) { this.clear_deleted_beziers() } } function draw_all_nodes(clear_deleted) { /** Draw all nodes, and clear deleted nodes. Arguments --------- clear_deleted: (Optional, Default: true) Boolean, if true, then also clear deleted nodes. */ if (clear_deleted === undefined) clear_deleted = true var node_ids = [] for (var node_id in this.nodes) { node_ids.push(node_id) } this.draw_these_nodes(node_ids) // clear the deleted nodes if (clear_deleted) this.clear_deleted_nodes() } function draw_these_nodes(node_ids) { /** Draw specific nodes. Does nothing with exit selection. Use clear_deleted_nodes to remove nodes from the DOM. Arguments --------- nodes_ids: An array of node_ids to update. */ // find reactions for reaction_ids var node_subset = utils.object_slice_for_ids_ref(this.nodes, node_ids) // functions to create and update nodes var create_fn = function(sel) { return this.draw.create_node(sel, this.nodes, this.reactions) }.bind(this) var update_fn = function (sel) { return this.draw.update_node(sel, this.scale, this.has_data_on_nodes, this.behavior.selectable_mousedown, this.behavior.selectable_click, this.behavior.node_mouseover, this.behavior.node_mouseout, this.behavior.selectable_drag, this.behavior.node_label_drag) }.bind(this) // draw the nodes utils.draw_an_object(this.sel, '#nodes', '.node', node_subset, 'node_id', create_fn, update_fn) } /** * Remove any nodes that are not in *this.nodes*. */ function clear_deleted_nodes() { // Run remove for exit selection utils.draw_an_object(this.sel, '#nodes', '.node', this.nodes, 'node_id', null, null, function (sel) { sel.remove() }) } /** * Draw all text_labels. */ function draw_all_text_labels () { this.draw_these_text_labels(Object.keys(this.text_labels)) // Clear all deleted text_labels this.clear_deleted_text_labels() } /** * Draw specific text_labels. Does nothing with exit selection. Use * clear_deleted_text_labels to remove text_labels from the DOM. * @param {Array} text_labels_ids - An array of text_label_ids to update. */ function draw_these_text_labels (text_label_ids) { // Find reactions for reaction_ids var text_label_subset = utils.object_slice_for_ids_ref(this.text_labels, text_label_ids) // Draw the text_labels utils.draw_an_object(this.sel, '#text-labels', '.text-label', text_label_subset, 'text_label_id', this.draw.create_text_label.bind(this.draw), this.draw.update_text_label.bind(this.draw)) } /** * Remove any text_labels that are not in *this.text_labels*. */ function clear_deleted_text_labels () { utils.draw_an_object(this.sel, '#text-labels', '.text-label', this.text_labels, 'text_label_id', null, null, function (sel) { sel.remove() }) } /** * Draw all beziers, and clear deleted reactions. */ function draw_all_beziers () { var bezier_ids = [] for (var bezier_id in this.beziers) { bezier_ids.push(bezier_id) } this.draw_these_beziers(bezier_ids) // clear delete beziers this.clear_deleted_beziers() } function draw_these_beziers(bezier_ids) { /** Draw specific beziers. Does nothing with exit selection. Use clear_deleted_beziers to remove beziers from the DOM. Arguments --------- beziers_ids: An array of bezier_ids to update. */ // find reactions for reaction_ids var bezier_subset = utils.object_slice_for_ids_ref(this.beziers, bezier_ids) // function to update beziers var update_fn = function(sel) { return this.draw.update_bezier(sel, this.beziers_enabled, this.behavior.bezier_drag, this.behavior.bezier_mouseover, this.behavior.bezier_mouseout, this.nodes, this.reactions) }.bind(this) // draw the beziers utils.draw_an_object(this.sel, '#beziers', '.bezier', bezier_subset, 'bezier_id', this.draw.create_bezier.bind(this.draw), update_fn) } function clear_deleted_beziers() { /** Remove any beziers that are not in *this.beziers*. */ // remove deleted utils.draw_an_object(this.sel, '#beziers', '.bezier', this.beziers, 'bezier_id', null, null, function(sel) { sel.remove(); }) } function show_beziers () { this.toggle_beziers(true) } function hide_beziers () { this.toggle_beziers(false) } function toggle_beziers (on_off) { if (_.isUndefined(on_off)) this.beziers_enabled = !this.beziers_enabled else this.beziers_enabled = on_off this.draw_all_beziers() this.callback_manager.run('toggle_beziers', null, this.beziers_enabled) } /** * Returns True if the scale has changed. * @param {Array} keys - (Optional) The keys in reactions to apply data to. */ function apply_reaction_data_to_map (data, keys) { var styles = this.settings.get_option('reaction_styles'), compare_style = this.settings.get_option('reaction_compare_style') var has_data = data_styles.apply_reaction_data_to_reactions(this.reactions, data, styles, compare_style, keys) this.has_data_on_reactions = has_data this.imported_reaction_data = has_data ? data : null return this.calc_data_stats('reaction') } /** * Returns True if the scale has changed. * @param {Array} keys - (Optional) The keys in nodes to apply data to. */ function apply_metabolite_data_to_map (data, keys) { var styles = this.settings.get_option('metabolite_styles') var compare_style = this.settings.get_option('metabolite_compare_style') var has_data = data_styles.apply_metabolite_data_to_nodes(this.nodes, data, styles, compare_style, keys) this.has_data_on_nodes = has_data this.imported_metabolite_data = has_data ? data : null return this.calc_data_stats('metabolite') } /** * Returns True if the scale has changed. * gene_data_obj: The gene data object, with the following style: * { reaction_id: { rule: 'rule_string', genes: { gene_id: value } } } * @param {Array} keys - (Optional) The keys in reactions to apply data to. */ function apply_gene_data_to_map (gene_data_obj, keys) { var styles = this.settings.get_option('reaction_styles'), compare_style = this.settings.get_option('reaction_compare_style'), identifiers_on_map = this.settings.get_option('identifiers_on_map'), and_method_in_gene_reaction_rule = this.settings.get_option('and_method_in_gene_reaction_rule') var has_data = data_styles.apply_gene_data_to_reactions(this.reactions, gene_data_obj, styles, identifiers_on_map, compare_style, and_method_in_gene_reaction_rule, keys) this.has_data_on_reactions = has_data this.imported_gene_data = has_data ? gene_data_obj : null return this.calc_data_stats('reaction') } // ------------------------------------------------ // Data domains // ------------------------------------------------ function get_data_statistics () { return this.data_statistics } function _on_array (fn) { return function (array) { return fn.apply(null, array) } } /** * Returns True if the stats have changed. * @param {String} type - Either 'metabolite' or 'reaction' */ function calc_data_stats (type) { if ([ 'reaction', 'metabolite' ].indexOf(type) === -1) { throw new Error('Bad type ' + type) } // make the data structure if (!('data_statistics' in this)) { this.data_statistics = {} this.data_statistics[type] = {} } else if (!(type in this.data_statistics)) { this.data_statistics[type] = {} } var same = true // default min and max var vals = [] if (type === 'metabolite') { for (var node_id in this.nodes) { var node = this.nodes[node_id] // check number if (_.isUndefined(node.data)) { console.error('metabolite missing ') } else if (node.data !== null) { vals.push(node.data) } } } else if (type == 'reaction') { for (var reaction_id in this.reactions) { var reaction = this.reactions[reaction_id] // check number if (_.isUndefined(reaction.data)) { console.error('reaction data missing ') } else if (reaction.data !== null) { vals.push(reaction.data) } } } // calculate these statistics var quartiles = utils.quartiles(vals) var funcs = [ [ 'min', _on_array(Math.min) ], [ 'max', _on_array(Math.max) ], [ 'mean', utils.mean ], [ 'Q1', function () { return quartiles[0] } ], [ 'median', function () { return quartiles[1] } ], [ 'Q3', function () { return quartiles[2] } ], ] funcs.forEach(function (ar) { var new_val var name = ar[0] if (vals.length === 0) { new_val = null } else { var fn = ar[1] new_val = fn(vals) } if (new_val != this.data_statistics[type][name]) { same = false } this.data_statistics[type][name] = new_val }.bind(this)) // Deal with max === min if (this.data_statistics[type]['min'] === this.data_statistics[type]['max'] && this.data_statistics[type]['min'] !== null) { var min = this.data_statistics[type]['min'] var max = this.data_statistics[type]['max'] this.data_statistics[type]['min'] = min - 1 - (Math.abs(min) * 0.1) this.data_statistics[type]['max'] = max + 1 + (Math.abs(max) * 0.1) } if (type === 'reaction') { this.callback_manager.run('calc_data_stats__reaction', null, !same) } else { this.callback_manager.run('calc_data_stats__metabolite', null, !same) } return !same } // --------------------------------------------------------------------- // Node interaction // --------------------------------------------------------------------- function get_coords_for_node (node_id) { var node = this.nodes[node_id], coords = { x: node.x, y: node.y } return coords } function get_selected_node_ids () { var selected_node_ids = [] this.sel.select('#nodes') .selectAll('.selected') .each(function (d) { selected_node_ids.push(d.node_id) }) return selected_node_ids } function get_selected_nodes () { var selected_nodes = {} this.sel.select('#nodes') .selectAll('.selected') .each(function(d) { selected_nodes[d.node_id] = this.nodes[d.node_id] }.bind(this)) return selected_nodes } function get_selected_text_label_ids () { var selected_text_label_ids = [] this.sel.select('#text-labels') .selectAll('.selected') .each(function (d) { selected_text_label_ids.push(d.text_label_id) }) return selected_text_label_ids } function get_selected_text_labels () { var selected_text_labels = {} this.sel.select('#text-labels') .selectAll('.selected') .each(function(d) { selected_text_labels[d.text_label_id] = this.text_labels[d.text_label_id] }.bind(this)) return selected_text_labels } function select_all() { /** Select all nodes and text labels. */ this.sel.selectAll('#nodes,#text-labels') .selectAll('.node,.text-label') .classed('selected', true) } function select_none() { /** Deselect all nodes and text labels. */ this.sel.selectAll('.selected') .classed('selected', false) } function invert_selection() { /** Invert selection of nodes and text labels. */ var selection = this.sel.selectAll('#nodes,#text-labels') .selectAll('.node,.text-label') selection.classed('selected', function() { return !d3_select(this).classed('selected') }) } function select_metabolite_with_id(node_id) { /** Select a metabolite with the given id, and turn off the reaction target. */ // deselect all text labels this.deselect_text_labels() var node_selection = this.sel.select('#nodes').selectAll('.node'), coords, selected_node node_selection.classed('selected', function(d) { var selected = String(d.node_id) == String(node_id) if (selected) { selected_node = d coords = { x: d.x, y: d.y } } return selected }) this.sel.selectAll('.start-reaction-target').style('visibility', 'hidden') this.callback_manager.run('select_metabolite_with_id', null, selected_node, coords) } function select_selectable(node, d, shift_key_on) { /** Select a metabolite or text label, and manage the shift key. */ shift_key_on = _.isUndefined(shift_key_on) ? false : shift_key_on var classable_selection = this.sel.selectAll('#nodes,#text-labels') .selectAll('.node,.text-label'), classable_node if (d3_select(node).attr('class').indexOf('text-label') == -1) { // node classable_node = node.parentNode } else { // text-label classable_node = node } // toggle selection if (shift_key_on) { // toggle this node d3_select(classable_node) .classed('selected', !d3_select(classable_node).classed('selected')) } else { // unselect all other nodes, and select this one classable_selection.classed('selected', false) d3_select(classable_node).classed('selected', true) } // run the select_metabolite callback var selected_nodes = this.sel.select('#nodes').selectAll('.selected'), node_count = 0, coords, selected_node selected_nodes.each(function(d) { selected_node = d coords = { x: d.x, y: d.y } node_count++ }) this.callback_manager.run('select_selectable', null, node_count, selected_node, coords) } /** * Unselect all but one selected node, and return the node. If no nodes are * selected, return null. */ function select_single_node () { var out = null var node_selection = this.sel.select('#nodes').selectAll('.selected') node_selection.classed('selected', function (d, i) { if (i === 0) { out = d return true } else { return false } }) return out } function deselect_nodes () { var node_selection = this.sel.select('#nodes').selectAll('.node') node_selection.classed('selected', false) this.callback_manager.run('deselect_nodes') } function select_text_label (sel, d) { // deselect all nodes this.deselect_nodes() // Find the new selection. Ignore shift key and only allow single selection // for now. var text_label_selection = this.sel.select('#text-labels').selectAll('.text-label') text_label_selection.classed('selected', function(p) { return d === p; }) var selected_text_labels = this.sel.select('#text-labels').selectAll('.selected'), coords selected_text_labels.each(function(d) { coords = { x: d.x, y: d.y } }) this.callback_manager.run('select_text_label') } function deselect_text_labels () { var text_label_selection = this.sel.select('#text-labels').selectAll('.text-label') text_label_selection.classed('selected', false) } // --------------------------------------------------------------------- // Delete // --------------------------------------------------------------------- /** * Delete the selected nodes and associated segments and reactions, and selected * labels. Undoable. */ function delete_selected () { var selected_nodes = this.get_selected_nodes(), selected_text_labels = this.get_selected_text_labels() if (Object.keys(selected_nodes).length >= 1 || Object.keys(selected_text_labels).length >= 1) this.delete_selectable(selected_nodes, selected_text_labels, true) } /** * Delete the nodes and associated segments and reactions. Undoable. * selected_nodes: An object that is a subset of map.nodes. * selected_text_labels: An object that is a subset of map.text_labels. * should_draw: A boolean argument to determine whether to draw the changes to * the map. */ function delete_selectable (selected_nodes, selected_text_labels, should_draw) { var out = this.segments_and_reactions_for_nodes(selected_nodes) var segment_objs_w_segments = out.segment_objs_w_segments // TODO repeated values here var reactions = out.reactions // copy nodes to undelete var saved_nodes = utils.clone(selected_nodes) var saved_segment_objs_w_segments = utils.clone(segment_objs_w_segments) var saved_reactions = utils.clone(reactions) var saved_text_labels = utils.clone(selected_text_labels) var delete_and_draw = function (nodes, reactions, segment_objs, selected_text_labels) { // delete nodes, segments, and reactions with no segments this.delete_node_data(Object.keys(selected_nodes)) this.delete_segment_data(segment_objs); // also deletes beziers this.delete_reaction_data(Object.keys(reactions)) this.delete_text_label_data(Object.keys(selected_text_labels)) // apply the reaction and node data var changed_r_scale = false var changed_m_scale = false if (this.has_data_on_reactions) { changed_r_scale = this.calc_data_stats('reaction') } if (this.has_data_on_nodes) { changed_m_scale = this.calc_data_stats('metabolite') } // redraw if (should_draw) { if (changed_r_scale) this.draw_all_reactions(true, true) else this.clear_deleted_reactions(); // also clears segments and beziers if (changed_m_scale) this.draw_all_nodes(true) else this.clear_deleted_nodes() this.clear_deleted_text_labels() } }.bind(this) // delete delete_and_draw(selected_nodes, reactions, segment_objs_w_segments, selected_text_labels) // add to undo/redo stack this.undo_stack.push(function () { // undo // redraw the saved nodes, reactions, and segments this.extend_nodes(saved_nodes) this.extend_reactions(saved_reactions) var reaction_ids_to_draw = Object.keys(saved_reactions) for (var segment_id in saved_segment_objs_w_segments) { var segment_obj = saved_segment_objs_w_segments[segment_id] var segment = segment_obj.segment this.reactions[segment_obj.reaction_id] .segments[segment_obj.segment_id] = segment // updated connected nodes var to_from = [ segment.from_node_id, segment.to_node_id ] to_from.forEach(function(node_id) { // not necessary for the deleted nodes if (node_id in saved_nodes) return var node = this.nodes[node_id] node.connected_segments.push({ reaction_id: segment_obj.reaction_id, segment_id: segment_obj.segment_id }) }.bind(this)) // extend the beziers var seg_id = segment_obj.segment_id, r_id = segment_obj.reaction_id, seg_o = {} seg_o[seg_id] = segment_obj.segment utils.extend(this.beziers, build.new_beziers_for_segments(seg_o, r_id)) if (reaction_ids_to_draw.indexOf(segment_obj.reaction_id) === -1) { reaction_ids_to_draw.push(segment_obj.reaction_id) } } // Apply the reaction and node data. If the scale changes, redraw // everything. if (this.has_data_on_reactions) { var scale_changed = this.calc_data_stats('reaction') if (scale_changed) this.draw_all_reactions(true, false) else this.draw_these_reactions(reaction_ids_to_draw) } else { if (should_draw) this.draw_these_reactions(reaction_ids_to_draw) } if (this.has_data_on_nodes) { var scale_changed = this.calc_data_stats('metabolite') if (should_draw) { if (scale_changed) this.draw_all_nodes(false) else this.draw_these_nodes(Object.keys(saved_nodes)) } } else { if (should_draw) this.draw_these_nodes(Object.keys(saved_nodes)) } // redraw the saved text_labels utils.extend(this.text_labels, saved_text_labels) if (should_draw) this.draw_these_text_labels(Object.keys(saved_text_labels)) // copy text_labels to re-delete selected_text_labels = utils.clone(saved_text_labels) // copy nodes to re-delete selected_nodes = utils.clone(saved_nodes) segment_objs_w_segments = utils.clone(saved_segment_objs_w_segments) reactions = utils.clone(saved_reactions) }.bind(this), function () { // redo // clone the nodes and reactions, to redo this action later delete_and_draw(selected_nodes, reactions, segment_objs_w_segments, selected_text_labels) }.bind(this)) } /** * Delete nodes, and remove from search index. */ function delete_node_data (node_ids) { node_ids.forEach(function(node_id) { if (this.enable_search && this.nodes[node_id].node_type=='metabolite') { var found = (this.search_index.remove('n' + node_id) && this.search_index.remove('n_name' + node_id)) if (!found) console.warn('Could not find deleted metabolite in search index') } delete this.nodes[node_id] }.bind(this)) } /** * Delete segments, update connected_segments in nodes, and delete bezier * points. * @param {Object} segment_objs - Object with values like * { reaction_id: '123', segment_id: '456' } */ function delete_segment_data (segment_objs) { for (var segment_id in segment_objs) { var segment_obj = segment_objs[segment_id] var reaction = this.reactions[segment_obj.reaction_id] // segment already deleted if (!(segment_obj.segment_id in reaction.segments)) return var segment = reaction.segments[segment_obj.segment_id] // updated connected nodes ;[segment.from_node_id, segment.to_node_id].forEach(function(node_id) { if (!(node_id in this.nodes)) return var node = this.nodes[node_id] node.connected_segments = node.connected_segments.filter(function(so) { return so.segment_id != segment_obj.segment_id }) }.bind(this)) // remove beziers ;['b1', 'b2'].forEach(function(bez) { var bez_id = build.bezier_id_for_segment_id(segment_obj.segment_id, bez) delete this.beziers[bez_id] }.bind(this)) delete reaction.segments[segment_obj.segment_id] } } /** * Delete reactions, segments, and beziers, and remove reaction from search * index. */ function delete_reaction_data (reaction_ids) { reaction_ids.forEach(function(reaction_id) { // remove beziers var reaction = this.reactions[reaction_id] for (var segment_id in reaction.segments) { ;['b1', 'b2'].forEach(function(bez) { var bez_id = build.bezier_id_for_segment_id(segment_id, bez) delete this.beziers[bez_id] }.bind(this)) } // delete reaction delete this.reactions[reaction_id] // remove from search index var found = (this.search_index.remove('r' + reaction_id) && this.search_index.remove('r_name' + reaction_id)) if (!found) console.warn('Could not find deleted reaction ' + reaction_id + ' in search index') for (var g_id in reaction.genes) { var found = (this.search_index.remove('r' + reaction_id + '_g' + g_id) && this.search_index.remove('r' + reaction_id + '_g_name' + g_id)) if (!found) console.warn('Could not find deleted gene ' + g_id + ' in search index') } }.bind(this)) } /** * Delete text labels for an array of IDs */ function delete_text_label_data (text_label_ids) { text_label_ids.forEach(function (text_label_id) { // delete label delete this.text_labels[text_label_id] // remove from search index var found = this.search_index.remove('l' + text_label_id) if (!found) { console.warn('Could not find deleted text label in search index') } }.bind(this)) } // --------------------------------------------------------------------- // Building // --------------------------------------------------------------------- function _extend_and_draw_metabolite (new_nodes, selected_node_id) { this.extend_nodes(new_nodes) var keys = [ selected_node_id ] if (this.has_data_on_nodes) { if (this.imported_metabolite_data === null) { throw new Error('imported_metabolite_data should not be null') } var scale_changed = this.apply_metabolite_data_to_map(this.imported_metabolite_data, keys) if (scale_changed) { this.draw_all_nodes(false) } else { this.draw_these_nodes(keys) } } else { this.draw_these_nodes(keys) } } /** * Draw a reaction on a blank canvas. * @param {String} starting_reaction - bigg_id for a reaction to draw. * @param {Coords} coords - coordinates to start drawing */ function new_reaction_from_scratch (starting_reaction, coords, direction) { // If there is no cobra model, error if (!this.cobra_model) { console.error('No CobraModel. Cannot build new reaction') return } // Set reaction coordinates and angle. Be sure to clone the reaction. var cobra_reaction = utils.clone(this.cobra_model.reactions[starting_reaction]) // check for empty reactions if (_.size(cobra_reaction.metabolites) === 0) { throw Error('No metabolites in reaction ' + cobra_reaction.bigg_id) } // create the first node var reactant_ids = _.map(cobra_reaction.metabolites, function (coeff, met_id) { return [ coeff, met_id ] }) .filter(function (x) { return x[0] < 0 }) // coeff < 0 .map(function (x) { return x[1] }) // metabolite id // get the first reactant or else the first product var metabolite_id = reactant_ids.length > 0 ? reactant_ids[0] : Object.keys(cobra_reaction.metabolites)[0] var metabolite = this.cobra_model.metabolites[metabolite_id] var selected_node_id = String(++this.largest_ids.nodes) var label_d = build.get_met_label_loc(Math.PI / 180 * direction, 0, 1, true, metabolite_id) var selected_node = { connected_segments: [], x: coords.x, y: coords.y, node_is_primary: true, label_x: coords.x + label_d.x, label_y: coords.y + label_d.y, name: metabolite.name, bigg_id: metabolite_id, node_type: 'metabolite' } var new_nodes = {} new_nodes[selected_node_id] = selected_node // draw _extend_and_draw_metabolite.apply(this, [ new_nodes, selected_node_id ]) // clone the nodes and reactions, to redo this action later var saved_nodes = utils.clone(new_nodes) // draw the reaction var out = this.new_reaction_for_metabolite(starting_reaction, selected_node_id, direction, false) var reaction_redo = out.redo var reaction_undo = out.undo // add to undo/redo stack this.undo_stack.push(function () { // Undo. First undo the reaction. reaction_undo() // Get the nodes to delete this.delete_node_data(Object.keys(new_nodes)) // Save the nodes and reactions again, for redo new_nodes = utils.clone(saved_nodes) // Draw this.clear_deleted_nodes() // Deselect this.deselect_nodes() }.bind(this), function () { // Redo. Clone the nodes and reactions, to redo this action later. _extend_and_draw_metabolite.apply(this, [ new_nodes, selected_node_id ]) // Now redo the reaction reaction_redo() }.bind(this)) return } /** * Add new nodes to data and search index. */ function extend_nodes (new_nodes) { if (this.enable_search) { for (var node_id in new_nodes) { var node = new_nodes[node_id] if (node.node_type != 'metabolite') continue this.search_index.insert('n' + node_id, { 'name': node.bigg_id, 'data': { type: 'metabolite', node_id: node_id }}) this.search_index.insert('n_name' + node_id, { 'name': node.name, 'data': { type: 'metabolite', node_id: node_id }}) } } utils.extend(this.nodes, new_nodes) } /** * Add new reactions to data and search index. */ function extend_reactions (new_reactions) { if (this.enable_search) { for (var r_id in new_reactions) { var reaction = new_reactions[r_id] this.search_index.insert('r' + r_id, { 'name': reaction.bigg_id, 'data': { type: 'reaction', reaction_id: r_id }}) this.search_index.insert('r_name' + r_id, { 'name': reaction.name, 'data': { type: 'reaction', reaction_id: r_id }}) for (var g_id in reaction.genes) { var gene = reaction.genes[g_id] this.search_index.insert('r' + r_id + '_g' + g_id, { 'name': gene.bigg_id, 'data': { type: 'reaction', reaction_id: r_id }}) this.search_index.insert('r' + r_id + '_g_name' + g_id, { 'name': gene.name, 'data': { type: 'reaction', reaction_id: r_id }}) } } } utils.extend(this.reactions, new_reactions) } function _extend_and_draw_reaction (ne