diff --git a/client/src/bundleEntries.js b/client/src/bundleEntries.js index acfb8027b076..2eec2a6b77bc 100644 --- a/client/src/bundleEntries.js +++ b/client/src/bundleEntries.js @@ -11,7 +11,6 @@ import $ from "jquery"; // eslint-disable-line no-unused-vars import Client from "mvc/visualization/chart/chart-client"; import _ from "underscore"; // eslint-disable-line no-unused-vars -import Circster from "viz/circster"; import { TracksterUIView } from "viz/trackster"; // Previously "chart" @@ -22,18 +21,12 @@ export { default as LegacyGridView } from "legacy/grid/grid-view"; export { createTabularDatasetChunkedView } from "mvc/dataset/data"; export { create_chart, create_histogram } from "reports/run_stats"; export { Toast } from "ui/toast"; // TODO: remove when external consumers are updated/gone (IES right now) -export { PhylovizView as phyloviz } from "viz/phyloviz"; -export { SweepsterVisualization, SweepsterVisualizationView } from "viz/sweepster"; export { TracksterUI } from "viz/trackster"; export function trackster(options) { new TracksterUIView(options); } -export function circster(options) { - new Circster.GalaxyApp(options); -} - // Previously wandering around as window.thing = thing in the onload script export { hide_modal, Modal, show_in_overlay, show_message, show_modal } from "layout/modal"; export { make_popup_menus, make_popupmenu } from "ui/popupmenu"; diff --git a/client/src/components/Grid/configs/visualizations.ts b/client/src/components/Grid/configs/visualizations.ts index f7818e8687a7..f9623f25e4a7 100644 --- a/client/src/components/Grid/configs/visualizations.ts +++ b/client/src/components/Grid/configs/visualizations.ts @@ -75,7 +75,11 @@ const fields: FieldArray = [ icon: faEye, condition: (data: VisualizationEntry) => !data.deleted, handler: (data: VisualizationEntry) => { - window.location.href = withPrefix(`/plugins/visualizations/${data.type}/saved?id=${data.id}`); + if (data.type === "trackster") { + window.location.href = withPrefix(`/visualization/${data.type}?id=${data.id}`); + } else { + window.location.href = withPrefix(`/plugins/visualizations/${data.type}/saved?id=${data.id}`); + } }, }, { diff --git a/client/src/viz/circster.js b/client/src/viz/circster.js deleted file mode 100644 index 6d0bba96b7a0..000000000000 --- a/client/src/viz/circster.js +++ /dev/null @@ -1,1218 +0,0 @@ -import "libs/farbtastic"; - -import { getGalaxyInstance } from "app"; -import Backbone from "backbone"; -import * as d3 from "d3v3"; -import { event as currentEvent } from "d3v3"; -import $ from "jquery"; -import mod_icon_btn from "mvc/ui/icon-button"; -import { getAppRoot } from "onload/loadConfig"; -import _ from "underscore"; -import config from "utils/config"; -import _l from "utils/localization"; -import mod_utils from "utils/utils"; -import visualization from "viz/visualization"; - -/** - * Utility class for working with SVG. - */ - -var SVGUtils = Backbone.Model.extend({ - /** - * Returns true if element is visible. - */ - is_visible: function (svg_elt, svg) { - var eltBRect = svg_elt.getBoundingClientRect(); - var svgBRect = $("svg")[0].getBoundingClientRect(); - - if ( - // To the left of screen? - eltBRect.right < 0 || - // To the right of screen? - eltBRect.left > svgBRect.right || - // Above screen? - eltBRect.bottom < 0 || - // Below screen? - eltBRect.top > svgBRect.bottom - ) { - return false; - } - return true; - }, -}); - -/** - * Mixin for using ticks. - */ -var UsesTicks = { - drawTicks: function (parent_elt, data, dataHandler, textTransform, horizontal) { - // Set up group elements for chroms and for each tick. - var ticks = parent_elt - .append("g") - .selectAll("g") - .data(data) - .enter() - .append("g") - .selectAll("g") - .data(dataHandler) - .enter() - .append("g") - .attr("class", "tick") - .attr("transform", (d) => `rotate(${(d.angle * 180) / Math.PI - 90})translate(${d.radius},0)`); - - // Add line + text for ticks. - var tick_coords = []; - - var text_coords = []; - - var text_anchor = (d) => (d.angle > Math.PI ? "end" : null); - - if (horizontal) { - tick_coords = [0, 0, 0, -4]; - text_coords = [4, 0, "", ".35em"]; - text_anchor = null; - } else { - tick_coords = [1, 0, 4, 0]; - text_coords = [0, 4, ".35em", ""]; - } - - ticks - .append("line") - .attr("x1", tick_coords[0]) - .attr("y1", tick_coords[1]) - .attr("x2", tick_coords[2]) - .attr("y1", tick_coords[3]) - .style("stroke", "#000"); - - return ticks - .append("text") - .attr("x", text_coords[0]) - .attr("y", text_coords[1]) - .attr("dx", text_coords[2]) - .attr("dy", text_coords[3]) - .attr("text-anchor", text_anchor) - .attr("transform", textTransform) - .text((d) => d.label); - }, - - /** - * Format number for display at a tick. - */ - formatNum: function (num, sigDigits) { - // Use default of 2 sig. digits. - if (sigDigits === undefined) { - sigDigits = 2; - } - - // Verify input number - if (num === null) { - return null; - } - - // Calculate return value - var rval = null; - if (Math.abs(num) < 1) { - rval = num.toPrecision(sigDigits); - } else { - // Use round to turn string from toPrecision() back into a number. - var roundedNum = Math.round(num.toPrecision(sigDigits)); - - // Use abbreviations. - num = Math.abs(num); - if (num < 1000) { - rval = roundedNum; - } else if (num < 1000000) { - // Use K. - rval = `${Math.round((roundedNum / 1000).toPrecision(3)).toFixed(0)}K`; - } else if (num < 1000000000) { - // Use M. - rval = `${Math.round((roundedNum / 1000000).toPrecision(3)).toFixed(0)}M`; - } - } - - return rval; - }, -}; - -/** - * A label track. - */ -var CircsterLabelTrack = Backbone.Model.extend({}); - -/** - * Renders a full circster visualization. - */ -var CircsterView = Backbone.View.extend({ - className: "circster", - - initialize: function (options) { - this.genome = options.genome; - this.label_arc_height = 50; - this.scale = 1; - this.circular_views = null; - this.chords_views = null; - - // When tracks added to/removed from model, update view. - this.model.get("drawables").on("add", this.add_track, this); - this.model.get("drawables").on("remove", this.remove_track, this); - - // When config settings change, update view. - var vis_config = this.model.get("config"); - vis_config.get("arc_dataset_height").on("change:value", this.update_track_bounds, this); - vis_config.get("track_gap").on("change:value", this.update_track_bounds, this); - }, - - // HACKs: using track_type for circular/chord distinction in the functions below for now. - - /** - * Returns tracks to be rendered using circular view. - */ - get_circular_tracks: function () { - return this.model.get("drawables").filter((track) => track.get("track_type") !== "DiagonalHeatmapTrack"); - }, - - /** - * Returns tracks to be rendered using chords view. - */ - get_chord_tracks: function () { - return this.model.get("drawables").filter((track) => track.get("track_type") === "DiagonalHeatmapTrack"); - }, - - /** - * Returns a list of circular tracks' radius bounds. - */ - get_tracks_bounds: function () { - var circular_tracks = this.get_circular_tracks(); - - var dataset_arc_height = this.model.get("config").get_value("arc_dataset_height"); - - var track_gap = this.model.get("config").get_value("track_gap"); - - var // Subtract 20 to make sure chrom labels are on screen. - min_dimension = Math.min(this.$el.width(), this.$el.height()) - 20; - - var // Compute radius start based on model, will be centered - // and fit entirely inside element by default. - radius_start = - min_dimension / 2 - - circular_tracks.length * (dataset_arc_height + track_gap) + - // Add track_gap back in because no gap is needed for last track. - track_gap - - this.label_arc_height; - - var // Compute range of track starting radii. - tracks_start_radii = d3.range(radius_start, min_dimension / 2, dataset_arc_height + track_gap); - - // Map from track start to bounds. - return _.map(tracks_start_radii, (radius) => [radius, radius + dataset_arc_height]); - }, - - /** - * Renders circular tracks, chord tracks, and label tracks. - */ - render: function () { - var self = this; - var width = self.$el.width(); - var height = self.$el.height(); - var circular_tracks = this.get_circular_tracks(); - var chords_tracks = this.get_chord_tracks(); - var total_gap = self.model.get("config").get_value("total_gap"); - var tracks_bounds = this.get_tracks_bounds(); - - var // Set up SVG element. - svg = d3 - .select(self.$el[0]) - .append("svg") - .attr("width", width) - .attr("height", height) - .attr("pointer-events", "all") - // Set up zooming, dragging. - .append("svg:g") - .call( - d3.behavior.zoom().on("zoom", () => { - // Do zoom, drag. - var scale = currentEvent.scale; - svg.attr("transform", `translate(${currentEvent.translate}) scale(${scale})`); - - // Propagate scale changes to views. - if (self.scale !== scale) { - // Use timeout to wait for zooming/dragging to stop before rendering more detail. - if (self.zoom_drag_timeout) { - clearTimeout(self.zoom_drag_timeout); - } - self.zoom_drag_timeout = setTimeout(() => { - // Render more detail in tracks' visible elements. - // FIXME: do not do this right now; it is not fully implemented--e.g. data bounds - // are not updated when new data is fetched--and fetching more detailed quantitative - // data is not that useful. - /* - _.each(self.circular_views, function(view) { - view.update_scale(scale); - }); - */ - }, 400); - } - }) - ) - .attr("transform", `translate(${width / 2},${height / 2})`) - .append("svg:g") - .attr("class", "tracks"); - - // -- Render circular tracks. -- - - // Create a view for each track in the visualization and render. - this.circular_views = circular_tracks.map((track, index) => { - var view = new CircsterBigWigTrackView({ - el: svg.append("g")[0], - track: track, - radius_bounds: tracks_bounds[index], - genome: self.genome, - total_gap: total_gap, - }); - - view.render(); - - return view; - }); - - // -- Render chords tracks. -- - - this.chords_views = chords_tracks.map((track) => { - var view = new CircsterChromInteractionsTrackView({ - el: svg.append("g")[0], - track: track, - radius_bounds: tracks_bounds[0], - genome: self.genome, - total_gap: total_gap, - }); - - view.render(); - - return view; - }); - - // -- Render label track. -- - - // Track bounds are: - // (a) outer radius of last circular track; - // (b) - var outermost_radius = this.circular_views[this.circular_views.length - 1].radius_bounds[1]; - - var track_bounds = [outermost_radius, outermost_radius + this.label_arc_height]; - - this.label_track_view = new CircsterChromLabelTrackView({ - el: svg.append("g")[0], - track: new CircsterLabelTrack(), - radius_bounds: track_bounds, - genome: self.genome, - total_gap: total_gap, - }); - - this.label_track_view.render(); - }, - - /** - * Render a single track on the outside of the current visualization. - */ - add_track: function (new_track) { - var total_gap = this.model.get("config").get_value("total_gap"); - - if (new_track.get("track_type") === "DiagonalHeatmapTrack") { - // Added chords track. - var innermost_radius_bounds = this.circular_views[0].radius_bounds; - - var new_view = new CircsterChromInteractionsTrackView({ - el: d3.select("g.tracks").append("g")[0], - track: new_track, - radius_bounds: innermost_radius_bounds, - genome: this.genome, - total_gap: total_gap, - }); - - new_view.render(); - this.chords_views.push(new_view); - } else { - // Added circular track. - - // Recompute and update circular track bounds. - var new_track_bounds = this.get_tracks_bounds(); - _.each(this.circular_views, (track_view, i) => { - track_view.update_radius_bounds(new_track_bounds[i]); - }); - - // Update chords tracks. - _.each(this.chords_views, (track_view) => { - track_view.update_radius_bounds(new_track_bounds[0]); - }); - - // Render new track. - var track_index = this.circular_views.length; - - var track_view = new CircsterBigWigTrackView({ - el: d3.select("g.tracks").append("g")[0], - track: new_track, - radius_bounds: new_track_bounds[track_index], - genome: this.genome, - total_gap: total_gap, - }); - - track_view.render(); - this.circular_views.push(track_view); - - // Update label track. - /* - FIXME: should never have to update label track because vis always expands to fit area - within label track. - var track_bounds = new_track_bounds[ new_track_bounds.length-1 ]; - track_bounds[1] = track_bounds[0]; - this.label_track_view.update_radius_bounds(track_bounds); - */ - } - }, - - /** - * Remove a track from the view. - */ - remove_track: function (track, tracks, options) { - // -- Remove track from view. -- - var track_view = this.circular_views[options.index]; - this.circular_views.splice(options.index, 1); - track_view.$el.remove(); - - // Recompute and update track bounds. - var new_track_bounds = this.get_tracks_bounds(); - _.each(this.circular_views, (track_view, i) => { - track_view.update_radius_bounds(new_track_bounds[i]); - }); - }, - - update_track_bounds: function () { - // Recompute and update track bounds. - var new_track_bounds = this.get_tracks_bounds(); - _.each(this.circular_views, (track_view, i) => { - track_view.update_radius_bounds(new_track_bounds[i]); - }); - - // Update chords tracks. - _.each(this.chords_views, (track_view) => { - track_view.update_radius_bounds(new_track_bounds[0]); - }); - }, -}); - -/** - * Renders a track in a Circster visualization. - */ -var CircsterTrackView = Backbone.View.extend({ - tagName: "g", - - /* ----------------------- Public Methods ------------------------- */ - - initialize: function (options) { - this.bg_stroke = "#ddd"; - // Fill color when loading data. - this.loading_bg_fill = "#ffc"; - // Fill color when data has been loaded. - this.bg_fill = "#ddd"; - this.total_gap = options.total_gap; - this.track = options.track; - this.radius_bounds = options.radius_bounds; - this.genome = options.genome; - this.chroms_layout = this._chroms_layout(); - this.data_bounds = []; - this.scale = 1; - this.parent_elt = d3.select(this.$el[0]); - }, - - /** - * Get fill color from config. - */ - get_fill_color: function () { - var color = this.track.get("config").get_value("block_color"); - if (!color) { - color = this.track.get("config").get_value("color"); - } - return color; - }, - - /** - * Render track's data by adding SVG elements to parent. - */ - render: function () { - // -- Create track group element. -- - var track_parent_elt = this.parent_elt; - - // -- Render background arcs. -- - var genome_arcs = this.chroms_layout; - - var arc_gen = d3.svg.arc().innerRadius(this.radius_bounds[0]).outerRadius(this.radius_bounds[1]); - - var // Attach data to group element. - chroms_elts = track_parent_elt.selectAll("g").data(genome_arcs).enter().append("svg:g"); - - var // Draw chrom arcs/paths. - chroms_paths = chroms_elts - .append("path") - .attr("d", arc_gen) - .attr("class", "chrom-background") - .style("stroke", this.bg_stroke) - .style("fill", this.loading_bg_fill); - - // Append titles to paths. - chroms_paths.append("title").text((d) => d.data.chrom); - - // -- Render track data and, when track data is rendered, apply preferences and update chrom_elts fill. -- - - var self = this; - - var data_manager = self.track.get("data_manager"); - - var // If track has a data manager, get deferred that resolves when data is ready. - data_ready_deferred = data_manager ? data_manager.data_is_ready() : true; - - // When data is ready, render track. - $.when(data_ready_deferred).then(() => { - $.when(self._render_data(track_parent_elt)).then(() => { - chroms_paths.style("fill", self.bg_fill); - - // Render labels after data is available so that data attributes are available. - self.render_labels(); - }); - }); - }, - - /** - * Render track labels. - */ - render_labels: function () {}, - - /** - * Update radius bounds. - */ - update_radius_bounds: function (radius_bounds) { - // Update bounds. - this.radius_bounds = radius_bounds; - - // -- Update background arcs. -- - var new_d = d3.svg.arc().innerRadius(this.radius_bounds[0]).outerRadius(this.radius_bounds[1]); - - this.parent_elt.selectAll("g>path.chrom-background").transition().duration(1000).attr("d", new_d); - - this._transition_chrom_data(); - - this._transition_labels(); - }, - - /** - * Update view scale. This fetches more data if scale is increased. - */ - update_scale: function (new_scale) { - // -- Update scale and return if new scale is less than old scale. -- - - var old_scale = this.scale; - this.scale = new_scale; - if (new_scale <= old_scale) { - return; - } - - // -- Scale increased, so render visible data with more detail. -- - - var self = this; - - var utils = new SVGUtils(); - - // Select all chrom data and filter to operate on those that are visible. - this.parent_elt - .selectAll("path.chrom-data") - .filter(function (d, i) { - return utils.is_visible(this); - }) - .each(function (d, i) { - // -- Now operating on a single path element representing chromosome data. -- - - var path_elt = d3.select(this); - - var chrom = path_elt.attr("chrom"); - var chrom_region = self.genome.get_chrom_region(chrom); - var data_manager = self.track.get("data_manager"); - var data_deferred; - - // If can't get more detailed data, return. - if (!data_manager.can_get_more_detailed_data(chrom_region)) { - return; - } - - // -- Get more detailed data. -- - data_deferred = self.track - .get("data_manager") - .get_more_detailed_data(chrom_region, "Coverage", 0, new_scale); - - // When more data is available, use new data to redraw path. - $.when(data_deferred).then((data) => { - // Remove current data path. - path_elt.remove(); - - // Update data bounds with new data. - self._update_data_bounds(); - - // Find chromosome arc to draw data on. - var chrom_arc = _.find(self.chroms_layout, (layout) => layout.data.chrom === chrom); - - // Add new data path and apply preferences. - var color = self.get_fill_color(); - self._render_chrom_data(self.parent_elt, chrom_arc, data) - .style("stroke", color) - .style("fill", color); - }); - }); - - return self; - }, - - /* ----------------------- Internal Methods ------------------------- */ - - /** - * Transitions chrom data to new values (e.g new radius or data bounds). - */ - _transition_chrom_data: function () { - var track = this.track; - var chrom_arcs = this.chroms_layout; - var chrom_data_paths = this.parent_elt.selectAll("g>path.chrom-data"); - var num_paths = chrom_data_paths[0].length; - - if (num_paths > 0) { - var self = this; - $.when(track.get("data_manager").get_genome_wide_data(this.genome)).then((genome_wide_data) => { - // Map chrom data to path data, filtering out null values. - var path_data = _.reject( - _.map(genome_wide_data, (chrom_data, i) => { - var rval = null; - - var path_fn = self._get_path_function(chrom_arcs[i], chrom_data); - - if (path_fn) { - rval = path_fn(chrom_data.data); - } - return rval; - }), - (p_data) => p_data === null - ); - - // Transition each path for data and color. - var color = track.get("config").get_value("color"); - chrom_data_paths.each(function (path, index) { - d3.select(this) - .transition() - .duration(1000) - .style("stroke", color) - .style("fill", color) - .attr("d", path_data[index]); - }); - }); - } - }, - - /** - * Transition labels to new values (e.g new radius or data bounds). - */ - _transition_labels: function () {}, - - /** - * Update data bounds. If there are new_bounds, use them; otherwise use - * default data bounds. - */ - _update_data_bounds: function (new_bounds) { - this.data_bounds = - new_bounds || this.get_data_bounds(this.track.get("data_manager").get_genome_wide_data(this.genome)); - this._transition_chrom_data(); - }, - - /** - * Render data as elements attached to svg. - */ - _render_data: function (svg) { - var self = this; - var chrom_arcs = this.chroms_layout; - var track = this.track; - var rendered_deferred = $.Deferred(); - - // When genome-wide data is available, render data. - $.when(track.get("data_manager").get_genome_wide_data(this.genome)).then((genome_wide_data) => { - // Set bounds. - self.data_bounds = self.get_data_bounds(genome_wide_data); - - // Set min, max value in config so that they can be adjusted. Make this silent - // because these attributes are watched for changes and the viz is updated - // accordingly (set up in initialize). Because we are setting up, we don't want - // the watch to trigger events here. - track.get("config").set_value("min_value", self.data_bounds[0], { - silent: true, - }); - track.get("config").set_value("max_value", self.data_bounds[1], { - silent: true, - }); - - // Merge chroms layout with data. - var layout_and_data = _.zip(chrom_arcs, genome_wide_data); - - // Render each chromosome's data. - _.each(layout_and_data, (chrom_info) => { - var chrom_arc = chrom_info[0]; - var data = chrom_info[1]; - return self._render_chrom_data(svg, chrom_arc, data); - }); - - // Apply prefs to all track data. - var color = self.get_fill_color(); - self.parent_elt.selectAll("path.chrom-data").style("stroke", color).style("fill", color); - - rendered_deferred.resolve(svg); - }); - - return rendered_deferred; - }, - - /** - * Render a chromosome data and attach elements to svg. - */ - _render_chrom_data: function (svg, chrom_arc, data) {}, - - /** - * Returns data for creating a path for the given data using chrom_arc and data bounds. - */ - _get_path_function: function (chrom_arc, chrom_data) {}, - - /** - * Returns arc layouts for genome's chromosomes/contigs. Arcs are arranged in a circle - * separated by gaps. - */ - _chroms_layout: function () { - // Setup chroms layout using pie. - var chroms_info = this.genome.get_chroms_info(); - - var pie_layout = d3.layout - .pie() - .value((d) => d.len) - .sort(null); - - var init_arcs = pie_layout(chroms_info); - var gap_per_chrom = (2 * Math.PI * this.total_gap) / chroms_info.length; - - var chrom_arcs = _.map(init_arcs, (arc, index) => { - // For short chroms, endAngle === startAngle. - var new_endAngle = arc.endAngle - gap_per_chrom; - arc.endAngle = new_endAngle > arc.startAngle ? new_endAngle : arc.startAngle; - return arc; - }); - - return chrom_arcs; - }, -}); - -/** - * Render chromosome labels. - */ -var CircsterChromLabelTrackView = CircsterTrackView.extend({ - initialize: function (options) { - CircsterTrackView.prototype.initialize.call(this, options); - // Use a single arc for rendering data. - this.innerRadius = this.radius_bounds[0]; - this.radius_bounds[0] = this.radius_bounds[1]; - this.bg_stroke = "#fff"; - this.bg_fill = "#fff"; - - // Minimum arc distance for labels to be applied. - this.min_arc_len = 0.05; - }, - - /** - * Render labels. - */ - _render_data: function (svg) { - // -- Add chromosome label where it will fit; an alternative labeling mechanism - // would be nice for small chromosomes. -- - var self = this; - - var chrom_arcs = svg.selectAll("g"); - - chrom_arcs.selectAll("path").attr("id", (d) => `label-${d.data.chrom}`); - - chrom_arcs - .append("svg:text") - .filter((d) => d.endAngle - d.startAngle > self.min_arc_len) - .attr("text-anchor", "middle") - .append("svg:textPath") - .attr("class", "chrom-label") - .attr("xlink:href", (d) => `#label-${d.data.chrom}`) - .attr("startOffset", "25%") - .text((d) => d.data.chrom); - - // -- Add ticks to denote chromosome length. -- - - /** Returns an array of tick angles and labels, given a chrom arc. */ - var chromArcTicks = (d) => { - var k = (d.endAngle - d.startAngle) / d.value; - - var ticks = d3.range(0, d.value, 25000000).map((v, i) => ({ - radius: self.innerRadius, - angle: v * k + d.startAngle, - label: i === 0 ? 0 : i % 3 ? null : self.formatNum(v), - })); - - // If there are fewer that 4 ticks, label last tick so that at least one non-zero tick is labeled. - if (ticks.length < 4) { - ticks[ticks.length - 1].label = self.formatNum( - Math.round((ticks[ticks.length - 1].angle - d.startAngle) / k) - ); - } - - return ticks; - }; - - /** Rotate and move text as needed. */ - var textTransform = (d) => (d.angle > Math.PI ? "rotate(180)translate(-16)" : null); - - // Filter chroms for only those large enough for display. - var visibleChroms = _.filter(this.chroms_layout, (c) => c.endAngle - c.startAngle > self.min_arc_len); - - this.drawTicks(this.parent_elt, visibleChroms, chromArcTicks, textTransform); - }, -}); -_.extend(CircsterChromLabelTrackView.prototype, UsesTicks); - -/** - * View for quantitative track in Circster. - */ -var CircsterQuantitativeTrackView = CircsterTrackView.extend({ - initialize: function (options) { - CircsterTrackView.prototype.initialize.call(this, options); - - // When config settings change, update view. - var track_config = this.track.get("config"); - track_config.get("min_value").on("change:value", this._update_min_max, this); - track_config.get("max_value").on("change:value", this._update_min_max, this); - track_config.get("color").on("change:value", this._transition_chrom_data, this); - }, - - /** - * Update track when min and/or max are changed. - */ - _update_min_max: function () { - var track_config = this.track.get("config"); - - var new_bounds = [track_config.get_value("min_value"), track_config.get_value("max_value")]; - - this._update_data_bounds(new_bounds); - - // FIXME: this works to update tick/text bounds, but there's probably a better way to do this - // by updating the data itself. - this.parent_elt.selectAll(".min_max").text((d, i) => new_bounds[i]); - }, - - /** - * Returns quantile for an array of numbers. - */ - _quantile: function (numbers, quantile) { - numbers.sort(d3.ascending); - return d3.quantile(numbers, quantile); - }, - - /** - * Renders quantitative data with the form [x, value] and assumes data is equally spaced across - * chromosome. Attachs a dict with track and chrom name information to DOM element. - */ - _render_chrom_data: function (svg, chrom_arc, chrom_data) { - var path_data = this._get_path_function(chrom_arc, chrom_data); - - if (!path_data) { - return null; - } - - // There is path data, so render as path. - var parent = svg.datum(chrom_data.data); - - var path = parent - .append("path") - .attr("class", "chrom-data") - .attr("chrom", chrom_arc.data.chrom) - .attr("d", path_data); - - return path; - }, - - /** - * Returns function for creating a path across the chrom arc. - */ - _get_path_function: function (chrom_arc, chrom_data) { - // If no chrom data, return null. - if (typeof chrom_data === "string" || !chrom_data.data || chrom_data.data.length === 0) { - return null; - } - - // Radius scaler. - var radius = d3.scale.linear().domain(this.data_bounds).range(this.radius_bounds).clamp(true); - - // Scaler for placing data points across arc. - var angle = d3.scale - .linear() - .domain([0, chrom_data.data.length]) - .range([chrom_arc.startAngle, chrom_arc.endAngle]); - - // Use line generator to create area. - var line = d3.svg.line - .radial() - .interpolate("linear") - .radius((d) => radius(d[1])) - .angle((d, i) => angle(i)); - - return d3.svg.area - .radial() - .interpolate(line.interpolate()) - .innerRadius(radius(0)) - .outerRadius(line.radius()) - .angle(line.angle()); - }, - - /** - * Render track min, max using ticks. - */ - render_labels: function () { - var self = this; - - var // Keep counter of visible chroms. - textTransform = () => "rotate(90)"; - - // FIXME: - // (1) using min_max class below is needed for _update_min_max, which could be improved. - // (2) showing config on tick click should be replaced by proper track config icon. - - // Draw min, max on first chrom only. - var ticks = this.drawTicks( - this.parent_elt, - [this.chroms_layout[0]], - this._data_bounds_ticks_fn(), - textTransform, - true - ).classed("min_max", true); - - // Show config when ticks are clicked on. - _.each(ticks, (tick) => { - $(tick).click(() => { - var view = new config.ConfigSettingCollectionView({ - collection: self.track.get("config"), - }); - view.render_in_modal("Configure Track"); - }); - }); - - /* - // Filter for visible chroms, then for every third chrom so that labels attached to only every - // third chrom. - var visibleChroms = _.filter(this.chroms_layout, function(c) { return c.endAngle - c.startAngle > 0.08; }), - labeledChroms = _.filter(visibleChroms, function(c, i) { return i % 3 === 0; }); - this.drawTicks(this.parent_elt, labeledChroms, this._data_bounds_ticks_fn(), textTransform, true); - */ - }, - - /** - * Transition labels to new values (e.g new radius or data bounds). - */ - _transition_labels: function () { - // FIXME: (a) pull out function for getting labeled chroms? and (b) function used in transition below - // is copied from UseTicks mixin, so pull out and make generally available. - - // If there are no data bounds, nothing to transition. - if (this.data_bounds.length === 0) { - return; - } - - // Transition labels to new radius bounds. - var self = this; - - var visibleChroms = _.filter(this.chroms_layout, (c) => c.endAngle - c.startAngle > 0.08); - - var labeledChroms = _.filter(visibleChroms, (c, i) => i % 3 === 0); - - var new_data = _.flatten(_.map(labeledChroms, (c) => self._data_bounds_ticks_fn()(c))); - - this.parent_elt - .selectAll("g.tick") - .data(new_data) - .transition() - .attr("transform", (d) => `rotate(${(d.angle * 180) / Math.PI - 90})translate(${d.radius},0)`); - }, - - /** - * Get function for locating data bounds ticks. - */ - _data_bounds_ticks_fn: function () { - // Closure vars. - var self = this; - - // Return function for locating ticks based on chrom arc data. - return ( - d // Set up data to display min, max ticks. - ) => [ - { - radius: self.radius_bounds[0], - angle: d.startAngle, - label: self.formatNum(self.data_bounds[0]), - }, - { - radius: self.radius_bounds[1], - angle: d.startAngle, - label: self.formatNum(self.data_bounds[1]), - }, - ]; - }, - - /** - * Returns an array with two values denoting the minimum and maximum - * values for the track. - */ - get_data_bounds: function (data) {}, -}); -_.extend(CircsterQuantitativeTrackView.prototype, UsesTicks); - -/** - * Bigwig track view in Circster. - */ -var CircsterBigWigTrackView = CircsterQuantitativeTrackView.extend({ - get_data_bounds: function (data) { - // Set max across dataset by extracting all values, flattening them into a - // single array, and getting third quartile. - var values = _.flatten( - _.map(data, (d) => { - if (d) { - // Each data point has the form [position, value], so return all values. - return _.map( - d.data, - ( - p // Null is used for a lack of data; resolve null to 0 for comparison. - ) => parseInt(p[1], 10) || 0 - ); - } else { - return 0; - } - }) - ); - - // For max, use 98% quantile in attempt to avoid very large values. However, this max may be 0 - // for sparsely populated data, so use max in that case. - return [_.min(values), this._quantile(values, 0.98) || _.max(values)]; - }, -}); - -/** - * Chromosome interactions track view in Circster. - */ -var CircsterChromInteractionsTrackView = CircsterTrackView.extend({ - render: function () { - var self = this; - - // When data is ready, render track. - $.when(self.track.get("data_manager").data_is_ready()).then(() => { - // When data has been fetched, render track. - $.when(self.track.get("data_manager").get_genome_wide_data(self.genome)).then((genome_wide_data) => { - var chord_data = []; - var chroms_info = self.genome.get_chroms_info(); - // Convert chromosome data into chord data. - _.each(genome_wide_data, (chrom_data, index) => { - // Map each interaction into chord data. - var cur_chrom = chroms_info[index].chrom; - var chrom_chord_data = _.map(chrom_data.data, (datum) => { - // Each datum is an interaction/chord. - var source_angle = self._get_region_angle(cur_chrom, datum[1]); - - var target_angle = self._get_region_angle(datum[3], datum[4]); - - return { - source: { - startAngle: source_angle, - endAngle: source_angle + 0.01, - }, - target: { - startAngle: target_angle, - endAngle: target_angle + 0.01, - }, - }; - }); - - chord_data = chord_data.concat(chrom_chord_data); - }); - - self.parent_elt - .append("g") - .attr("class", "chord") - .selectAll("path") - .data(chord_data) - .enter() - .append("path") - .style("fill", self.get_fill_color()) - .attr("d", d3.svg.chord().radius(self.radius_bounds[0])) - .style("opacity", 1); - }); - }); - }, - - update_radius_bounds: function (radius_bounds) { - this.radius_bounds = radius_bounds; - this.parent_elt.selectAll("path").transition().attr("d", d3.svg.chord().radius(this.radius_bounds[0])); - }, - - /** - * Returns radians for a genomic position. - */ - _get_region_angle: function (chrom, position) { - // Find chrom angle data - var chrom_angle_data = _.find(this.chroms_layout, (chrom_layout) => chrom_layout.data.chrom === chrom); - - // Return angle at position. - return ( - chrom_angle_data.endAngle - - ((chrom_angle_data.endAngle - chrom_angle_data.startAngle) * (chrom_angle_data.data.len - position)) / - chrom_angle_data.data.len - ); - }, -}); - -// circster app loader -var Circster = Backbone.View.extend({ - initialize: function () { - // load css - mod_utils.cssLoadFile("static/style/circster.css"); - // -- Configure visualization -- - var genome = new visualization.Genome(window.galaxy_config.app.genome); - - var vis = new visualization.GenomeVisualization(window.galaxy_config.app.viz_config); - - // Add Circster-specific config options. - vis.get("config").add([ - { - key: "arc_dataset_height", - label: "Arc Dataset Height", - type: "int", - value: 25, - view: "circster", - }, - { - key: "track_gap", - label: "Gap Between Tracks", - type: "int", - value: 5, - view: "circster", - }, - { - key: "total_gap", - label: "Gap [0-1]", - type: "float", - value: 0.4, - view: "circster", - hidden: true, - }, - ]); - - var viz_view = new CircsterView({ - // view pane - el: $("#center .unified-panel-body"), - genome: genome, - model: vis, - }); - - // Render vizualization - viz_view.render(); - - // setup title - $("#center .unified-panel-header-inner").append( - `${window.galaxy_config.app.viz_config.title} ${window.galaxy_config.app.viz_config.dbkey}` - ); - - // setup menu - var menu = mod_icon_btn.create_icon_buttons_menu( - [ - { - icon_class: "plus-button", - title: _l("Add tracks"), - on_click: function () { - visualization.select_datasets({ dbkey: vis.get("dbkey") }, (tracks) => { - vis.add_tracks(tracks); - }); - }, - }, - { - icon_class: "gear", - title: _l("Settings"), - on_click: function () { - var view = new config.ConfigSettingCollectionView({ - collection: vis.get("config"), - }); - view.render_in_modal("Configure Visualization"); - }, - }, - { - icon_class: "disk--arrow", - title: _l("Save"), - on_click: function () { - const Galaxy = getGalaxyInstance(); - - // show saving dialog box - Galaxy.modal.show({ - title: _l("Saving..."), - body: "progress", - }); - - // send to server - $.ajax({ - url: `${getAppRoot()}visualization/save`, - type: "POST", - dataType: "json", - data: { - id: vis.get("vis_id"), - title: vis.get("title"), - dbkey: vis.get("dbkey"), - type: "trackster", - vis_json: JSON.stringify(vis), - }, - }) - .success((vis_info) => { - Galaxy.modal.hide(); - vis.set("vis_id", vis_info.vis_id); - }) - .error(() => { - // show dialog - Galaxy.modal.show({ - title: _l("Could Not Save"), - body: "Could not save visualization. Please try again later.", - buttons: { - Cancel: function () { - Galaxy.modal.hide(); - }, - }, - }); - }); - }, - }, - { - icon_class: "cross-circle", - title: _l("Close"), - on_click: function () { - window.top.location = `${getAppRoot()}visualizations/list`; - }, - }, - ], - { tooltip_config: { placement: "bottom" } } - ); - - // add menu - menu.$el.attr("style", "float: right"); - $("#center .unified-panel-header-inner").append(menu.$el); - - // manual tooltip config because default gravity is S and cannot be changed - $(".menu-button").tooltip({ placement: "bottom" }); - }, -}); - -// Module exports. -export default { - GalaxyApp: Circster, -}; diff --git a/client/src/viz/phyloviz.js b/client/src/viz/phyloviz.js deleted file mode 100644 index 9020b0cd0c2b..000000000000 --- a/client/src/viz/phyloviz.js +++ /dev/null @@ -1,1092 +0,0 @@ -import Backbone from "backbone"; -import * as d3 from "d3v3"; -import $ from "jquery"; -import { hide_modal, show_message } from "layout/modal"; -import { Dataset } from "mvc/dataset/data"; -import mod_icon_btn from "mvc/ui/icon-button"; -import _l from "utils/localization"; -import visualization_mod from "viz/visualization"; - -/** - * Base class of any menus that takes in user interaction. Contains checking methods. - */ -var UserMenuBase = Backbone.View.extend({ - className: "UserMenuBase", - - /** - * Check if an input value is a number and falls within max min. - */ - isAcceptableValue: function ($inputKey, min, max) { - //TODO: use better feedback than alert - var value = $inputKey.val(); - - var fieldName = $inputKey.attr("displayLabel") || $inputKey.attr("id").replace("phyloViz", ""); - - function isNumeric(n) { - return !isNaN(parseFloat(n)) && isFinite(n); - } - - if (!isNumeric(value)) { - alert(`${fieldName} is not a number!`); - return false; - } - - if (value > max) { - alert(`${fieldName} is too large.`); - return false; - } else if (value < min) { - alert(`${fieldName} is too small.`); - return false; - } - return true; - }, - - /** - * Check if any user string inputs has illegal characters that json cannot accept - */ - hasIllegalJsonCharacters: function ($inputKey) { - if ($inputKey.val().search(/"|'|\\/) !== -1) { - alert( - "Named fields cannot contain these illegal characters: " + - "double quote(\"), single guote('), or back slash(\\). " - ); - return true; - } - return false; - }, -}); - -/** - * -- Custom Layout call for phyloViz to suit the needs of a phylogenetic tree. - * -- Specifically: 1) Nodes have a display display of (= evo dist X depth separation) from their parent - * 2) Nodes must appear in other after they have expand and contracted - */ -function PhyloTreeLayout() { - var self = this; // maximum length of the text labels - - var hierarchy = d3.layout.hierarchy().sort(null).value(null); - - var // ! represents both the layout angle and the height of the layout, in px - height = 360; - - var layoutMode = "Linear"; - - var // height of each individual leaf node - leafHeight = 18; - - var // separation between nodes of different depth, in px - depthSeparation = 200; - - var // change to recurssive call - leafIndex = 0; - - var // tree defaults to 0.5 dist if no dist is specified - defaultDist = 0.5; - - var maxTextWidth = 50; - - self.leafHeight = (inputLeafHeight) => { - if (typeof inputLeafHeight === "undefined") { - return leafHeight; - } else { - leafHeight = inputLeafHeight; - return self; - } - }; - - self.layoutMode = (mode) => { - if (typeof mode === "undefined") { - return layoutMode; - } else { - layoutMode = mode; - return self; - } - }; - - // changes the layout angle of the display, which is really changing the height - self.layoutAngle = (angle) => { - if (typeof angle === "undefined") { - return height; - } - // to use default if the user puts in strange values - if (isNaN(angle) || angle < 0 || angle > 360) { - return self; - } else { - height = angle; - return self; - } - }; - - self.separation = (dist) => { - // changes the dist between the nodes of different depth - if (typeof dist === "undefined") { - return depthSeparation; - } else { - depthSeparation = dist; - return self; - } - }; - - self.links = ( - nodes // uses d3 native method to generate links. Done. - ) => d3.layout.tree().links(nodes); - - // -- Custom method for laying out phylogeny tree in a linear fashion - self.nodes = (d, i) => { - //TODO: newick and phyloxml return arrays. where should this go (client (here, else), server)? - if (toString.call(d) === "[object Array]") { - // if d is an array, replate with the first object (newick, phyloxml) - d = d[0]; - } - - // self is to find the depth of all the nodes, assumes root is passed in - var _nodes = hierarchy.call(self, d, i); - - var nodes = []; - var maxDepth = 0; - var numLeaves = 0; - //console.debug( JSON.stringify( _nodes, null, 2 ) ) - window._d = d; - window._nodes = _nodes; - - //TODO: remove dbl-touch loop - // changing from hierarchy's custom format for data to usable format - _nodes.forEach((node) => { - maxDepth = node.depth > maxDepth ? node.depth : maxDepth; //finding max depth of tree - nodes.push(node); - }); - // counting the number of leaf nodes and assigning max depth - // to nodes that do not have children to flush all the leave nodes - nodes.forEach((node) => { - if (!node.children) { - //&& !node._children - numLeaves += 1; - node.depth = maxDepth; // if a leaf has no child it would be assigned max depth - } - }); - - leafHeight = layoutMode === "Circular" ? height / numLeaves : leafHeight; - leafIndex = 0; - layout(nodes[0], maxDepth, leafHeight, null); - - return nodes; - }; - - /** - * -- Function with side effect of adding x0, y0 to all child; take in the root as starting point - * assuming that the leave nodes would be sorted in presented order - * horizontal(y0) is calculated according to (= evo dist X depth separation) from their parent - * vertical (x0) - if leave node: find its order in all of the leave node === node.id, - * then multiply by verticalSeparation - * - if parent node: is place in the mid point all of its children nodes - * -- The layout will first calculate the y0 field going towards the leaves, and x0 when returning - */ - function layout(node, maxDepth, vertSeparation, parent) { - var children = node.children; - var sumChildVertSeparation = 0; - - // calculation of node's dist from parents, going down. - var dist = node.dist || defaultDist; - dist = dist > 1 ? 1 : dist; // We constrain all dist to be less than one - node.dist = dist; - if (parent !== null) { - node.y0 = parent.y0 + dist * depthSeparation; - } else { - //root node - node.y0 = maxTextWidth; - } - - // if a node have no children, we will treat it as a leaf and start laying it out first - if (!children) { - node.x0 = leafIndex * vertSeparation; - leafIndex += 1; - } else { - // if it has children, we will visit all its children and calculate its position from its children - children.forEach((child) => { - child.parent = node; - sumChildVertSeparation += layout(child, maxDepth, vertSeparation, node); - }); - node.x0 = sumChildVertSeparation / children.length; - } - - // adding properties to the newly created node - node.x = node.x0; - node.y = node.y0; - return node.x0; - } - return self; -} - -/** - * -- PhyloTree Model -- - */ -var PhyloTree = visualization_mod.Visualization.extend({ - defaults: { - layout: "Linear", - separation: 250, // px dist between nodes of different depth to represent 1 evolutionary until - leafHeight: 18, - type: "phyloviz", // visualization type - title: _l("Title"), - scaleFactor: 1, - translate: [0, 0], - fontSize: 12, //fontSize of node label - selectedNode: null, - nodeAttrChangedTime: 0, - }, - - initialize: function (options) { - this.set( - "dataset", - new Dataset({ - id: options.dataset_id, - }) - ); - }, - - root: {}, // Root has to be its own independent object because it is not part of the viz_config - - /** - * Mechanism to expand or contract a single node. Expanded nodes have a children list, while for - * contracted nodes the list is stored in _children. Nodes with their children data stored in _children will not - * have their children rendered. - */ - toggle: function (d) { - if (typeof d === "undefined") { - return; - } - if (d.children) { - d._children = d.children; - d.children = null; - } else { - d.children = d._children; - d._children = null; - } - }, - - /** - * Contracts the phylotree to a single node by repeatedly calling itself to place all the list - * of children under _children. - */ - toggleAll: function (d) { - if (d.children && d.children.length !== 0) { - d.children.forEach(this.toggleAll); - this.toggle(d); - } - }, - - /** - * Return the data of the tree. Used for preserving state. - */ - getData: function () { - return this.root; - }, - - /** - * Overriding the default save mechanism to do some clean of circular reference of the - * phyloTree and to include phyloTree in the saved json - */ - save: function () { - var root = this.root; - cleanTree(root); - //this.set("root", root); - - function cleanTree(node) { - // we need to remove parent to delete circular reference - delete node.parent; - - // removing unnecessary attributes - if (node._selected) { - delete node._selected; - } - - if (node.children) { - node.children.forEach(cleanTree); - } - if (node._children) { - node._children.forEach(cleanTree); - } - } - - var config = $.extend(true, {}, this.attributes); - config.selectedNode = null; - - show_message("Saving to Galaxy", "progress"); - - return $.ajax({ - url: this.url(), - type: "POST", - dataType: "json", - data: { - config: JSON.stringify(config), - type: "phyloviz", - }, - success: function (res) { - hide_modal(); - }, - }); - }, -}); - -// -- Views -- -/** - * Stores the default variable for setting up the visualization - */ -var PhylovizLayoutBase = Backbone.View.extend({ - defaults: { - nodeRadius: 4.5, // radius of each node in the diagram - }, - - /** - * Common initialization in layouts - */ - stdInit: function (options) { - var self = this; - self.model.on( - "change:separation change:leafHeight change:fontSize change:nodeAttrChangedTime", - self.updateAndRender, - self - ); - - self.vis = options.vis; - self.i = 0; - self.maxDepth = -1; // stores the max depth of the tree - - self.width = options.width; - self.height = options.height; - }, - - /** - * Updates the visualization whenever there are changes in the expansion and contraction of nodes - * AND possibly when the tree is edited. - */ - updateAndRender: function (source) { - var self = this; - source = source || self.model.root; - - self.renderNodes(source); - self.renderLinks(source); - self.addTooltips(); - }, - - /** - * Renders the links for the visualization. - */ - renderLinks: function (source) { - var self = this; - var link = self.vis.selectAll("g.completeLink").data(self.tree.links(self.nodes), (d) => d.target.id); - - var calcalateLinePos = (d) => { - // position of the source node <=> starting location of the line drawn - d.pos0 = `${d.source.y0} ${d.source.x0}`; - // position where the line makes a right angle bend - d.pos1 = `${d.source.y0} ${d.target.x0}`; - // point where the horizontal line becomes a dotted line - d.pos2 = `${d.target.y0} ${d.target.x0}`; - }; - - var linkEnter = link.enter().insert("svg:g", "g.node").attr("class", "completeLink"); - - linkEnter - .append("svg:path") - .attr("class", "link") - .attr("d", (d) => { - calcalateLinePos(d); - return `M ${d.pos0} L ${d.pos1}`; - }); - - var linkUpdate = link.transition().duration(500); - - linkUpdate.select("path.link").attr("d", (d) => { - calcalateLinePos(d); - return `M ${d.pos0} L ${d.pos1} L ${d.pos2}`; - }); - - link.exit().remove(); - }, - - // User Interaction methods below - - /** - * Displays the information for editing - */ - selectNode: function (node) { - var self = this; - d3.selectAll("g.node").classed("selectedHighlight", (d) => { - if (node.id === d.id) { - if (node._selected) { - // for de=selecting node. - delete node._selected; - return false; - } else { - node._selected = true; - return true; - } - } - return false; - }); - - self.model.set("selectedNode", node); - $("#phyloVizSelectedNodeName").val(node.name); - $("#phyloVizSelectedNodeDist").val(node.dist); - $("#phyloVizSelectedNodeAnnotation").val(node.annotation || ""); - }, - - /** - * Creates bootstrap tooltip for the visualization. Has to be called repeatedly due to newly generated - * enterNodes - */ - addTooltips: function () { - $(".tooltip").remove(); //clean up tooltip, just in case its listeners are removed by d3 - $(".node") - .attr("data-original-title", function () { - var d = this.__data__; - var annotation = d.annotation || "None"; - return d - ? `${d.name ? `${d.name}
` : ""}Dist: ${d.dist}
Annotation1: ${annotation}${ - d.bootstrap ? `
Confidence level: ${Math.round(100 * d.bootstrap)}` : "" - }` - : ""; - }) - .tooltip({ placement: "top", trigger: "hover" }); - }, -}); - -/** - * Linea layout class of Phyloviz, is responsible for rendering the nodes - * calls PhyloTreeLayout to determine the positions of the nodes - */ -var PhylovizLinearView = PhylovizLayoutBase.extend({ - initialize: function (options) { - // Default values of linear layout - var self = this; - self.margins = options.margins; - self.layoutMode = "Linear"; - - self.stdInit(options); - - self.layout(); - self.updateAndRender(self.model.root); - }, - - /** - * Creates the basic layout of a linear tree by precalculating fixed values. - * One of calculations are also made here - */ - layout: function () { - var self = this; - self.tree = new PhyloTreeLayout().layoutMode("Linear"); - self.diagonal = d3.svg.diagonal().projection((d) => [d.y, d.x]); - }, - - /** - * Renders the nodes base on Linear layout. - */ - renderNodes: function (source) { - var self = this; - var fontSize = `${self.model.get("fontSize")}px`; - - // assigning properties from models - self.tree.separation(self.model.get("separation")).leafHeight(self.model.get("leafHeight")); - - var duration = 500; - - var nodes = self.tree.separation(self.model.get("separation")).nodes(self.model.root); - - var node = self.vis.selectAll("g.node").data(nodes, (d) => d.name + d.id || (d.id = ++self.i)); - - // These variables has to be passed into update links which are in the base methods - self.nodes = nodes; - self.duration = duration; - - // ------- D3 ENTRY -------- - // Enter any new nodes at the parent's previous position. - var nodeEnter = node - .enter() - .append("svg:g") - .attr("class", "node") - .on("dblclick", () => { - d3.event.stopPropagation(); - }) - .on("click", (d) => { - if (d3.event.altKey) { - self.selectNode(d); // display info if alt is pressed - } else { - if (d.children && d.children.length === 0) { - return; - } // there is no need to toggle leaves - self.model.toggle(d); // contract/expand nodes at data level - self.updateAndRender(d); // re-render the tree - } - }); - //TODO: newick and phyloxml return arrays. where should this go (client (here, else), server)? - if (toString.call(source) === "[object Array]") { - // if d is an array, replate with the first object (newick, phyloxml) - source = source[0]; - } - nodeEnter.attr("transform", (d) => `translate(${source.y0},${source.x0})`); - - nodeEnter - .append("svg:circle") - .attr("r", 1e-6) - .style("fill", (d) => (d._children ? "lightsteelblue" : "#fff")); - - nodeEnter - .append("svg:text") - .attr("class", "nodeLabel") - .attr("x", (d) => (d.children || d._children ? -10 : 10)) - .attr("dy", ".35em") - .attr("text-anchor", (d) => (d.children || d._children ? "end" : "start")) - .style("fill-opacity", 1e-6); - - // ------- D3 TRANSITION -------- - // Transition nodes to their new position. - var nodeUpdate = node.transition().duration(duration); - - nodeUpdate.attr("transform", (d) => `translate(${d.y},${d.x})`); - - nodeUpdate - .select("circle") - .attr("r", self.defaults.nodeRadius) - .style("fill", (d) => (d._children ? "lightsteelblue" : "#fff")); - - nodeUpdate - .select("text") - .style("fill-opacity", 1) - .style("font-size", fontSize) - .text((d) => (d.name && d.name !== "" ? d.name : d.bootstrap ? Math.round(100 * d.bootstrap) : "")); - - // ------- D3 EXIT -------- - // Transition exiting nodes to the parent's new position. - var nodeExit = node.exit().transition().duration(duration).remove(); - - nodeExit.select("circle").attr("r", 1e-6); - - nodeExit.select("text").style("fill-opacity", 1e-6); - - // Stash the old positions for transition. - nodes.forEach((d) => { - d.x0 = d.x; // we need the x0, y0 for parents with children - d.y0 = d.y; - }); - }, -}); - -export var PhylovizView = Backbone.View.extend({ - className: "phyloviz", - - initialize: function (options) { - var self = this; - // -- Default values of the vis - self.MIN_SCALE = 0.05; //for zooming - self.MAX_SCALE = 5; - self.MAX_DISPLACEMENT = 500; - self.margins = [10, 60, 10, 80]; - - self.width = $("#PhyloViz").width(); - self.height = $("#PhyloViz").height(); - self.radius = self.width; - self.data = options.data; - - // -- Events Phyloviz view responses to - $(window).resize(() => { - self.width = $("#PhyloViz").width(); - self.height = $("#PhyloViz").height(); - self.render(); - }); - - // -- Create phyloTree model - self.phyloTree = new PhyloTree(options.config); - self.phyloTree.root = self.data; - - // -- Set up UI functions of main view - self.zoomFunc = d3.behavior.zoom().scaleExtent([self.MIN_SCALE, self.MAX_SCALE]); - self.zoomFunc.translate(self.phyloTree.get("translate")); - self.zoomFunc.scale(self.phyloTree.get("scaleFactor")); - - // -- set up header buttons, search and settings menu - self.navMenu = new HeaderButtons(self); - self.settingsMenu = new SettingsMenu({ - phyloTree: self.phyloTree, - }); - self.nodeSelectionView = new NodeSelectionView({ - phyloTree: self.phyloTree, - }); - self.search = new PhyloVizSearch(); - - // using settimeout to call the zoomAndPan function according to the stored attributes in viz_config - setTimeout(() => { - self.zoomAndPan(); - }, 1000); - }, - - render: function () { - // -- Creating helper function for vis. -- - var self = this; - $("#PhyloViz").empty(); - - // -- Layout viz. -- - self.mainSVG = d3 - .select("#PhyloViz") - .append("svg:svg") - .attr("width", self.width) - .attr("height", self.height) - .attr("pointer-events", "all") - .call( - self.zoomFunc.on("zoom", () => { - self.zoomAndPan(); - }) - ); - - self.boundingRect = self.mainSVG - .append("svg:rect") - .attr("class", "boundingRect") - .attr("width", self.width) - .attr("height", self.height) - .attr("stroke", "black") - .attr("fill", "white"); - - self.vis = self.mainSVG.append("svg:g").attr("class", "vis"); - - self.layoutOptions = { - model: self.phyloTree, - width: self.width, - height: self.height, - vis: self.vis, - margins: self.margins, - }; - - // -- Creating Title - $("#title").text(`Phylogenetic Tree from ${self.phyloTree.get("title")}:`); - - // -- Create Linear view instance -- - new PhylovizLinearView(self.layoutOptions); - }, - - /** - * Function to zoom and pan the svg element which the entire tree is contained within - * Uses d3.zoom events, and extend them to allow manual updates and keeping states in model - */ - zoomAndPan: function (event) { - var zoomParams; - var translateParams; - if (typeof event !== "undefined") { - zoomParams = event.zoom; - translateParams = event.translate; - } - - var self = this; - var scaleFactor = self.zoomFunc.scale(); - var translationCoor = self.zoomFunc.translate(); - var zoomStatement = ""; - var translateStatement = ""; - - // Do manual scaling. - switch (zoomParams) { - case "reset": - scaleFactor = 1.0; - translationCoor = [0, 0]; - break; - case "+": - scaleFactor *= 1.1; - break; - case "-": - scaleFactor *= 0.9; - break; - default: - if (typeof zoomParams === "number") { - scaleFactor = zoomParams; - } else if (d3.event !== null) { - scaleFactor = d3.event.scale; - } - } - if (scaleFactor < self.MIN_SCALE || scaleFactor > self.MAX_SCALE) { - return; - } - self.zoomFunc.scale(scaleFactor); //update scale Factor - zoomStatement = `translate(${self.margins[3]},${self.margins[0]}) scale(${scaleFactor})`; - - // Do manual translation. - if (d3.event !== null) { - translateStatement = `translate(${d3.event.translate})`; - } else { - if (typeof translateParams !== "undefined") { - var x = translateParams.split(",")[0]; - var y = translateParams.split(",")[1]; - if (!isNaN(x) && !isNaN(y)) { - translationCoor = [translationCoor[0] + parseFloat(x), translationCoor[1] + parseFloat(y)]; - } - } - self.zoomFunc.translate(translationCoor); // update zoomFunc - translateStatement = `translate(${translationCoor})`; - } - - self.phyloTree.set("scaleFactor", scaleFactor); - self.phyloTree.set("translate", translationCoor); - //refers to the view that we are actually zooming - self.vis.attr("transform", translateStatement + zoomStatement); - }, - - /** - * Primes the Ajax URL to load another Nexus tree - */ - reloadViz: function () { - var self = this; - var treeIndex = $("#phylovizNexSelector :selected").val(); - $.getJSON( - self.phyloTree.get("dataset").url(), - { - tree_index: treeIndex, - data_type: "raw_data", - }, - (packedJson) => { - self.data = packedJson.data; - self.config = packedJson; - self.render(); - } - ); - }, -}); - -var HeaderButtons = Backbone.View.extend({ - initialize: function (phylovizView) { - var self = this; - self.phylovizView = phylovizView; - - // Clean up code - if the class initialized more than once - $("#panelHeaderRightBtns").empty(); - $("#phyloVizNavBtns").empty(); - $("#phylovizNexSelector").off(); - - self.initNavBtns(); - self.initRightHeaderBtns(); - - // Initial a tree selector in the case of nexus - $("#phylovizNexSelector") - .off() - .on("change", () => { - self.phylovizView.reloadViz(); - }); - }, - - initRightHeaderBtns: function () { - var self = this; - - var rightMenu = mod_icon_btn.create_icon_buttons_menu( - [ - { - icon_class: "gear", - title: _l("PhyloViz Settings"), - on_click: function () { - $("#SettingsMenu").show(); - self.settingsMenu.updateUI(); - }, - }, - { - icon_class: "disk", - title: _l("Save visualization"), - on_click: function () { - var nexSelected = $("#phylovizNexSelector option:selected").text(); - if (nexSelected) { - self.phylovizView.phyloTree.set("title", nexSelected); - } - self.phylovizView.phyloTree.save(); - }, - }, - { - icon_class: "chevron-expand", - title: "Search / Edit Nodes", - on_click: function () { - $("#nodeSelectionView").show(); - }, - }, - { - icon_class: "information", - title: _l("Phyloviz Help"), - on_click: function () { - window.open("https://galaxyproject.org/learn/visualization/phylogenetic-tree/"); - // https://docs.google.com/document/d/1AXFoJgEpxr21H3LICRs3EyMe1B1X_KFPouzIgrCz3zk/edit - }, - }, - ], - { - tooltip_config: { placement: "bottom" }, - } - ); - $("#panelHeaderRightBtns").append(rightMenu.$el); - }, - - initNavBtns: function () { - var self = this; - - var navMenu = mod_icon_btn.create_icon_buttons_menu( - [ - { - icon_class: "zoom-in", - title: _l("Zoom in"), - on_click: function () { - self.phylovizView.zoomAndPan({ zoom: "+" }); - }, - }, - { - icon_class: "zoom-out", - title: _l("Zoom out"), - on_click: function () { - self.phylovizView.zoomAndPan({ zoom: "-" }); - }, - }, - { - icon_class: "arrow-circle", - title: "Reset Zoom/Pan", - on_click: function () { - self.phylovizView.zoomAndPan({ - zoom: "reset", - }); - }, - }, - ], - { - tooltip_config: { placement: "bottom" }, - } - ); - - $("#phyloVizNavBtns").append(navMenu.$el); - }, -}); - -var SettingsMenu = UserMenuBase.extend({ - className: "Settings", - - initialize: function (options) { - // settings needs to directly interact with the phyloviz model so it will get access to it. - var self = this; - self.phyloTree = options.phyloTree; - self.el = $("#SettingsMenu"); - self.inputs = { - separation: $("#phyloVizTreeSeparation"), - leafHeight: $("#phyloVizTreeLeafHeight"), - fontSize: $("#phyloVizTreeFontSize"), - }; - - //init all buttons of settings - $("#settingsCloseBtn") - .off() - .on("click", () => { - self.el.hide(); - }); - $("#phylovizResetSettingsBtn") - .off() - .on("click", () => { - self.resetToDefaults(); - }); - $("#phylovizApplySettingsBtn") - .off() - .on("click", () => { - self.apply(); - }); - }, - - /** - * Applying user values to phylotree model. - */ - apply: function () { - var self = this; - if ( - !self.isAcceptableValue(self.inputs.separation, 50, 2500) || - !self.isAcceptableValue(self.inputs.leafHeight, 5, 30) || - !self.isAcceptableValue(self.inputs.fontSize, 5, 20) - ) { - return; - } - $.each(self.inputs, (key, $input) => { - self.phyloTree.set(key, $input.val()); - }); - }, - /** - * Called to update the values input to that stored in the model - */ - updateUI: function () { - var self = this; - $.each(self.inputs, (key, $input) => { - $input.val(self.phyloTree.get(key)); - }); - }, - /** - * Resets the value of the phyloTree model to its default - */ - resetToDefaults: function () { - $(".tooltip").remove(); // just in case the tool tip was not removed - var self = this; - $.each(self.phyloTree.defaults, (key, value) => { - self.phyloTree.set(key, value); - }); - self.updateUI(); - }, - - render: function () {}, -}); - -/** - * View for inspecting node properties and editing them - */ -var NodeSelectionView = UserMenuBase.extend({ - className: "Settings", - - initialize: function (options) { - var self = this; - self.el = $("#nodeSelectionView"); - self.phyloTree = options.phyloTree; - - self.UI = { - enableEdit: $("#phylovizEditNodesCheck"), - saveChanges: $("#phylovizNodeSaveChanges"), - cancelChanges: $("#phylovizNodeCancelChanges"), - name: $("#phyloVizSelectedNodeName"), - dist: $("#phyloVizSelectedNodeDist"), - annotation: $("#phyloVizSelectedNodeAnnotation"), - }; - - // temporarily stores the values in case user change their mind - self.valuesOfConcern = { - name: null, - dist: null, - annotation: null, - }; - - //init UI buttons - $("#nodeSelCloseBtn") - .off() - .on("click", () => { - self.el.hide(); - }); - self.UI.saveChanges.off().on("click", () => { - self.updateNodes(); - }); - self.UI.cancelChanges.off().on("click", () => { - self.cancelChanges(); - }); - - (($) => { - // extending jquery fxn for enabling and disabling nodes. - $.fn.enable = function (isEnabled) { - return $(this).each(function () { - if (isEnabled) { - $(this).removeAttr("disabled"); - } else { - $(this).attr("disabled", "disabled"); - } - }); - }; - })($); - - self.UI.enableEdit.off().on("click", () => { - self.toggleUI(); - }); - }, - - /** - * For turning on and off the child elements - */ - toggleUI: function () { - var self = this; - var checked = self.UI.enableEdit.is(":checked"); - - if (!checked) { - self.cancelChanges(); - } - - $.each(self.valuesOfConcern, (key, value) => { - self.UI[key].enable(checked); - }); - if (checked) { - self.UI.saveChanges.show(); - self.UI.cancelChanges.show(); - } else { - self.UI.saveChanges.hide(); - self.UI.cancelChanges.hide(); - } - }, - - /** - * Reverting to previous values in case user change their minds - */ - cancelChanges: function () { - var self = this; - var node = self.phyloTree.get("selectedNode"); - if (node) { - $.each(self.valuesOfConcern, (key, value) => { - self.UI[key].val(node[key]); - }); - } - }, - - /** - * Changing the data in the underlying tree with user-specified values - */ - updateNodes: function () { - var self = this; - var node = self.phyloTree.get("selectedNode"); - if (node) { - if ( - !self.isAcceptableValue(self.UI.dist, 0, 1) || - self.hasIllegalJsonCharacters(self.UI.name) || - self.hasIllegalJsonCharacters(self.UI.annotation) - ) { - return; - } - $.each(self.valuesOfConcern, (key, value) => { - node[key] = self.UI[key].val(); - }); - self.phyloTree.set("nodeAttrChangedTime", new Date()); - } else { - alert("No node selected"); - } - }, -}); - -/** - * Initializes the search panel on phyloviz and handles its user interaction - * It allows user to search the entire free based on some qualifer, like dist <= val. - */ -var PhyloVizSearch = UserMenuBase.extend({ - initialize: function () { - var self = this; - - $("#phyloVizSearchBtn").on("click", () => { - var searchTerm = $("#phyloVizSearchTerm"); - - var searchConditionVal = $("#phyloVizSearchCondition").val().split("-"); - - var attr = searchConditionVal[0]; - var condition = searchConditionVal[1]; - self.hasIllegalJsonCharacters(searchTerm); - - if (attr === "dist") { - self.isAcceptableValue(searchTerm, 0, 1); - } - self.searchTree(attr, condition, searchTerm.val()); - }); - }, - - /** - * Searches the entire tree and will highlight the nodes that match the condition in green - */ - searchTree: function (attr, condition, val) { - d3.selectAll("g.node").classed("searchHighlight", (d) => { - var attrVal = d[attr]; - if (typeof attrVal !== "undefined" && attrVal !== null) { - if (attr === "dist") { - switch (condition) { - case "greaterEqual": - return attrVal >= +val; - case "lesserEqual": - return attrVal <= +val; - default: - return; - } - } else if (attr === "name" || attr === "annotation") { - return attrVal.toLowerCase().indexOf(val.toLowerCase()) !== -1; - } - } - }); - }, -}); diff --git a/client/src/viz/sweepster.js b/client/src/viz/sweepster.js deleted file mode 100644 index 175516d83f40..000000000000 --- a/client/src/viz/sweepster.js +++ /dev/null @@ -1,1033 +0,0 @@ -/** - * Visualization and components for Sweepster, a visualization for exploring a tool's parameter space via - * genomic visualization. - */ - -import Backbone from "backbone"; -import * as d3 from "d3v3"; -import $ from "jquery"; -import { hide_modal, show_modal } from "layout/modal"; -import { Dataset } from "mvc/dataset/data"; -import mod_icon_btn from "mvc/ui/icon-button"; -import { getAppRoot } from "onload/loadConfig"; -import { make_popupmenu } from "ui/popupmenu"; -import _ from "underscore"; -import config from "utils/config"; -import _l from "utils/localization"; -import tools from "viz/tools"; -import tracks from "viz/trackster/tracks"; -import visualization from "viz/visualization"; - -/** - * A collection of tool input settings. Object is useful for keeping a list of settings - * for future use without changing the input's value and for preserving inputs order. - */ -var ToolInputsSettings = Backbone.Model.extend({ - defaults: { - inputs: null, - values: null, - }, -}); - -/** - * Tree for a tool's parameters. - */ -var ToolParameterTree = Backbone.Model.extend({ - defaults: { - tool: null, - tree_data: null, - }, - - initialize: function (options) { - // Set up tool parameters to work with tree. - var self = this; - this.get("tool") - .get("inputs") - .each((input) => { - // Listen for changes to input's attributes. - input.on( - "change:min change:max change:num_samples", - (input) => { - if (input.get("in_ptree")) { - self.set_tree_data(); - } - }, - self - ); - input.on( - "change:in_ptree", - (input) => { - if (input.get("in_ptree")) { - self.add_param(input); - } else { - self.remove_param(input); - } - self.set_tree_data(); - }, - self - ); - }); - - // If there is a config, use it. - if (options.config) { - _.each(options.config, (input_config) => { - var input = self - .get("tool") - .get("inputs") - .find((input) => input.get("name") === input_config.name); - self.add_param(input); - input.set(input_config); - }); - } - }, - - add_param: function (param) { - // If parameter already present, do not add it. - if (param.get("ptree_index")) { - return; - } - - param.set("in_ptree", true); - param.set("ptree_index", this.get_tree_params().length); - }, - - remove_param: function (param) { - // Remove param from tree. - param.set("in_ptree", false); - param.set("ptree_index", null); - - // Update ptree indices for remaining params. - _(this.get_tree_params()).each((input, index) => { - // +1 to use 1-based indexing. - input.set("ptree_index", index + 1); - }); - }, - - /** - * Sets tree data using tool's inputs. - */ - set_tree_data: function () { - // Get samples for each parameter. - var params_samples = _.map(this.get_tree_params(), (param) => ({ - param: param, - samples: param.get_samples(), - })); - var node_id = 0; - - var // Creates tree data recursively. - create_tree_data = (params_samples, index) => { - var param_samples = params_samples[index]; - var param = param_samples.param; - var settings = param_samples.samples; - - // Create leaves when last parameter setting is reached. - if (params_samples.length - 1 === index) { - return _.map(settings, (setting) => ({ - id: node_id++, - name: setting, - param: param, - value: setting, - })); - } - - // Recurse to handle other parameters. - return _.map(settings, (setting) => ({ - id: node_id++, - name: setting, - param: param, - value: setting, - children: create_tree_data(params_samples, index + 1), - })); - }; - - this.set("tree_data", { - name: "Root", - id: node_id++, - children: params_samples.length !== 0 ? create_tree_data(params_samples, 0) : null, - }); - }, - - get_tree_params: function () { - // Filter and sort parameters to get list in tree. - return _(this.get("tool").get("inputs").where({ in_ptree: true })).sortBy((input) => input.get("ptree_index")); - }, - - /** - * Returns number of leaves in tree. - */ - get_num_leaves: function () { - return this.get_tree_params().reduce((memo, param) => memo * param.get_samples().length, 1); - }, - - /** - * Returns array of ToolInputsSettings objects based on a node and its subtree. - */ - get_node_settings: function (target_node) { - // -- Get fixed settings from tool and parent nodes. - - // Start with tool's settings. - var fixed_settings = this.get("tool").get_inputs_dict(); - - // Get fixed settings using node's parents. - var cur_node = target_node.parent; - if (cur_node) { - while (cur_node.depth !== 0) { - fixed_settings[cur_node.param.get("name")] = cur_node.value; - cur_node = cur_node.parent; - } - } - - // Walk subtree starting at clicked node to get full list of settings. - var self = this; - - var get_settings = (node, settings) => { - // Add setting for this node. Root node does not have a param, - // however. - if (node.param) { - settings[node.param.get("name")] = node.value; - } - - if (!node.children) { - // At leaf node, so return settings. - return new ToolInputsSettings({ - inputs: self.get("tool").get("inputs"), - values: settings, - }); - } else { - // At interior node: return list of subtree settings. - return _.flatten(_.map(node.children, (c) => get_settings(c, _.clone(settings)))); - } - }; - - var all_settings = get_settings(target_node, fixed_settings); - - // If user clicked on leaf, settings is a single dict. Convert to array for simplicity. - if (!_.isArray(all_settings)) { - all_settings = [all_settings]; - } - - return all_settings; - }, - - /** - * Returns all nodes connected a particular node; this includes parents and children of the node. - */ - get_connected_nodes: function (node) { - var get_subtree_nodes = (a_node) => { - if (!a_node.children) { - return a_node; - } else { - // At interior node: return subtree nodes. - return _.flatten([a_node, _.map(a_node.children, (c) => get_subtree_nodes(c))]); - } - }; - - // Get node's parents. - var parents = []; - - var cur_parent = node.parent; - while (cur_parent) { - parents.push(cur_parent); - cur_parent = cur_parent.parent; - } - - return _.flatten([parents, get_subtree_nodes(node)]); - }, - - /** - * Returns the leaf that corresponds to a settings collection. - */ - get_leaf: function (settings) { - var cur_node = this.get("tree_data"); - - var find_child = (children) => _.find(children, (child) => settings[child.param.get("name")] === child.value); - - while (cur_node.children) { - cur_node = find_child(cur_node.children); - } - return cur_node; - }, - - /** - * Returns a list of parameters used in tree. - */ - toJSON: function () { - // FIXME: returning and jsonifying complete param causes trouble on the server side, - // so just use essential attributes for now. - return this.get_tree_params().map((param) => ({ - name: param.get("name"), - min: param.get("min"), - max: param.get("max"), - num_samples: param.get("num_samples"), - })); - }, -}); - -var SweepsterTrack = Backbone.Model.extend({ - defaults: { - track: null, - mode: "Pack", - settings: null, - regions: null, - }, - - initialize: function (options) { - this.set("regions", options.regions); - if (options.track) { - // FIXME: find a better way to deal with needed URLs: - var track_config = _.extend( - { - data_url: `${getAppRoot()}dummy1`, - converted_datasets_state_url: `${getAppRoot()}dummy2`, - }, - options.track - ); - this.set("track", tracks.object_from_template(track_config, {}, null)); - } - }, - - same_settings: function (a_track) { - var this_settings = this.get("settings"); - var other_settings = a_track.get("settings"); - for (var prop in this_settings) { - if (!other_settings[prop] || this_settings[prop] !== other_settings[prop]) { - return false; - } - } - return true; - }, - - toJSON: function () { - return { - track: this.get("track").to_dict(), - settings: this.get("settings"), - regions: this.get("regions"), - }; - }, -}); - -var TrackCollection = Backbone.Collection.extend({ - model: SweepsterTrack, -}); - -/** - * Sweepster visualization model. - */ -export var SweepsterVisualization = visualization.Visualization.extend({ - defaults: _.extend({}, visualization.Visualization.prototype.defaults, { - dataset: null, - tool: null, - parameter_tree: null, - regions: null, - tracks: null, - default_mode: "Pack", - }), - - initialize: function (options) { - this.set("dataset", new Dataset(options.dataset)); - this.set("tool", new tools.Tool(options.tool)); - this.set("regions", new visualization.GenomeRegionCollection(options.regions)); - this.set("tracks", new TrackCollection(options.tracks)); - - var tool_with_samplable_inputs = this.get("tool"); - this.set("tool_with_samplable_inputs", tool_with_samplable_inputs); - // Remove complex parameters for now. - tool_with_samplable_inputs.remove_inputs(["data", "hidden_data", "conditional", "text"]); - - this.set( - "parameter_tree", - new ToolParameterTree({ - tool: tool_with_samplable_inputs, - config: options.tree_config, - }) - ); - }, - - add_track: function (track) { - this.get("tracks").add(track); - }, - - toJSON: function () { - return { - id: this.get("id"), - title: `Parameter exploration for dataset '${this.get("dataset").get("name")}'`, - type: "sweepster", - dataset_id: this.get("dataset").id, - tool_id: this.get("tool").id, - regions: this.get("regions").toJSON(), - tree_config: this.get("parameter_tree").toJSON(), - tracks: this.get("tracks").toJSON(), - }; - }, -}); - -/** - * --- Views --- - */ - -/** - * Sweepster track view. - */ -var SweepsterTrackView = Backbone.View.extend({ - tagName: "tr", - - TILE_LEN: 250, - - initialize: function (options) { - this.canvas_manager = options.canvas_manager; - this.render(); - this.model.on("change:track change:mode", this.draw_tiles, this); - }, - - render: function () { - // Render settings icon and popup. - // TODO: use template. - var settings = this.model.get("settings"); - - var values = settings.get("values"); - - var settings_td = $("").addClass("settings").appendTo(this.$el); - - var settings_div = $("
").addClass("track-info").hide().appendTo(settings_td); - - settings_div.append($("
").css("font-weight", "bold").text("Track Settings")); - settings.get("inputs").each((input) => { - settings_div.append(`${input.get("label")}: ${values[input.get("name")]}
`); - }); - var self = this; - - $("