From 3c1983a3d426d437ed3be965a0287aca2372d92c Mon Sep 17 00:00:00 2001 From: Carl Eberhard Date: Fri, 7 Feb 2014 11:24:32 -0500 Subject: [PATCH] Visualizations framework: allow for saving and loading registry visualizations, allow better merging of kwargs, config, saved config, add config utilities to common/templates; Scatterplot: allow saving as visualization, more mvc-based, increase max zoom, refactor and cleanup; Charts, Scatterplot: remove visualization data_source --- .../visualizations/charts/config/charts.xml | 6 - .../common/templates/config_utils.mako | 46 +++ .../templates}/visualization_base.mako | 0 .../scatterplot/config/scatterplot.xml | 6 - .../src/handlebars/editor.handlebars | 10 +- .../src/scatterplot-config-editor.js | 250 +++++++------ .../scatterplot/src/scatterplot-display.js | 354 +++++++++--------- .../scatterplot/src/scatterplot.js | 27 +- .../scatterplot/static/scatterplot-edit.js | 2 +- .../scatterplot/static/scatterplot.css | 18 +- .../scatterplot/templates/scatterplot.mako | 36 +- lib/galaxy/visualization/registry.py | 139 ++++--- .../galaxy/controllers/visualization.py | 107 +++++- 13 files changed, 583 insertions(+), 418 deletions(-) create mode 100644 config/plugins/visualizations/common/templates/config_utils.mako rename config/plugins/visualizations/{ => common/templates}/visualization_base.mako (100%) diff --git a/config/plugins/visualizations/charts/config/charts.xml b/config/plugins/visualizations/charts/config/charts.xml index d18b3a4e5d3..98623288dc3 100644 --- a/config/plugins/visualizations/charts/config/charts.xml +++ b/config/plugins/visualizations/charts/config/charts.xml @@ -7,15 +7,9 @@ tabular.Tabular dataset_id - - Visualization - charts - visualization_id - dataset_id - visualization_id diff --git a/config/plugins/visualizations/common/templates/config_utils.mako b/config/plugins/visualizations/common/templates/config_utils.mako new file mode 100644 index 00000000000..49f5e188604 --- /dev/null +++ b/config/plugins/visualizations/common/templates/config_utils.mako @@ -0,0 +1,46 @@ +<%def name="add_config_defaults( defaults )"> +## overwrite default_config_dict with config (if any) then assign to config +<% + for key, default in defaults.items(): + if key not in config or config[ key ] is None: + config[ key ] = default +%> + + +<%def name="config_form( config_dict )"> +## render form for everything in possible config + + +<%def name="link_to_change_config( link_contents, new_settings, target='' )"> +<% + # assumes there's a config var + url_for_args = { + 'controller' : 'visualization', + 'action' : 'render', + 'visualization_name' : visualization_name, + 'title' : title + } + url_for_args.update( config ) + url_for_args.update( new_settings ) + if visualization_id: + url_for_args[ 'id' ] = visualization_id +%> + ${link_contents} + + +<%def name="save_button( text='Save' )"> +<% + # still a GET + url_for_args = { + 'controller' : 'visualization', + 'action' : 'saved', + 'type' : visualization_name, + 'title' : title, + 'config' : h.to_json_string( config ) + } + # save to existing visualization + if visualization_id: + url_for_args[ 'id' ] = visualization_id +%> +
+ diff --git a/config/plugins/visualizations/visualization_base.mako b/config/plugins/visualizations/common/templates/visualization_base.mako similarity index 100% rename from config/plugins/visualizations/visualization_base.mako rename to config/plugins/visualizations/common/templates/visualization_base.mako diff --git a/config/plugins/visualizations/scatterplot/config/scatterplot.xml b/config/plugins/visualizations/scatterplot/config/scatterplot.xml index cfecc5f3847..642b528dd0f 100644 --- a/config/plugins/visualizations/scatterplot/config/scatterplot.xml +++ b/config/plugins/visualizations/scatterplot/config/scatterplot.xml @@ -7,15 +7,9 @@ tabular.Tabular dataset_id - - Visualization - scatterplot - visualization_id - dataset_id - visualization_id diff --git a/config/plugins/visualizations/scatterplot/src/handlebars/editor.handlebars b/config/plugins/visualizations/scatterplot/src/handlebars/editor.handlebars index 3d6b256c883..5b0859bd07b 100644 --- a/config/plugins/visualizations/scatterplot/src/handlebars/editor.handlebars +++ b/config/plugins/visualizations/scatterplot/src/handlebars/editor.handlebars @@ -10,14 +10,20 @@ Chart Controls - {{! both stats and chart start as disabled since there's no info yet }} + {{! chart starts as disabled since there's no info yet }}
  • Chart
  • + {{! ... }} +
  • + + +
  • - {{! data form, chart config form, stats, and chart all get their own tab }} + {{! data form, chart config form, chart all get their own tab }}
    {{! ---------------------------- tab for data settings form }}
    diff --git a/config/plugins/visualizations/scatterplot/src/scatterplot-config-editor.js b/config/plugins/visualizations/scatterplot/src/scatterplot-config-editor.js index 204deefeb90..1089f511a57 100644 --- a/config/plugins/visualizations/scatterplot/src/scatterplot-config-editor.js +++ b/config/plugins/visualizations/scatterplot/src/scatterplot-config-editor.js @@ -1,24 +1,22 @@ /* ============================================================================= todo: - Remove 'chart' names - Make this (the config control/editor) and the ScatterplotView (in scatterplot.js) both - views onto a visualization/revision model + import button(display), func(model) - when user doesn't match Move margins into wid/hi calcs (so final svg dims are w/h) Better separation of AJAX in scatterplot.js (maybe pass in function?) Labels should auto fill in chart control when dataset has column_names Allow column selection/config using the peek output as a base for UI - Allow setting perPage of config - Auto render if given data and/or config + Allow setting perPage in config Allow option to auto set width/height based on screen real estate avail. Handle large number of pages better (Known genes hg19) Use d3.nest to allow grouping, pagination/filtration by group (e.g. chromCol) Semantic HTML (figure, caption) - Save as visualization, load from visualization Save as SVG/png Does it work w/ Galaxy.Frame? Embedding Small multiples Drag & Drop other splots onto current (redraw with new axis and differentiate the datasets) + Remove 'chart' names + Somehow link out from info box? Subclass on specific datatypes? (vcf, cuffdiff, etc.) What can be common/useful to other visualizations? @@ -40,138 +38,190 @@ var ScatterplotConfigEditor = Backbone.View.extend( LoggableMixin ).extend({ if( !this.model ){ this.model = new Visualization({ type: 'scatterplot' }); } - console.log( this + '.initialize, attributes:', attributes ); + this.log( this + '.initialize, attributes:', attributes ); if( !attributes || !attributes.dataset ){ throw new Error( "ScatterplotConfigEditor requires a dataset" ); } this.dataset = attributes.dataset; - console.log( 'dataset:', this.dataset ); + this.log( 'dataset:', this.dataset ); -//TODO: ScatterplotView -> ScatterplotDisplay, this.plotView -> this.display - this.plotView = new ScatterplotView({ + this.display = new ScatterplotDisplay({ dataset : attributes.dataset, model : this.model -//TODO: if data }); }, // ------------------------------------------------------------------------- CONTROLS RENDERING render : function(){ //console.log( this + '.render' ); - // render the tab controls, areas and loading indicator - this.$el.append( ScatterplotConfigEditor.templates.mainLayout({})); - - // render the tab content - this.$el.find( '#data-control' ).append( this._render_dataControl() ); - this._render_chartControls( this.$el.find( '#chart-control' ) ); - //this.$statsDisplay = this.$el.find( '.tab-pane#stats-display' ); - this._render_chartDisplay(); - - //TODO: auto render if given both x, y column choices in query for page - - // set up behaviours + this.$el.empty().append( ScatterplotConfigEditor.templates.mainLayout({})); + if( this.model.id ){ + this.$el.find( '.copy-btn' ).show(); + this.$el.find( '.save-btn' ).text( 'Update saved' ); + } this.$el.find( '[title]' ).tooltip(); - // uncomment any of the following to have that tab show on initial load (for testing) - //this.$el.find( 'ul.nav' ).find( 'a[href="#data-control"]' ).tab( 'show' ); - //this.$el.find( 'ul.nav' ).find( 'a[href="#chart-control"]' ).tab( 'show' ); - //this.$el.find( 'ul.nav' ).find( 'a[href="#stats-display"]' ).tab( 'show' ); - //this.$el.find( 'ul.nav' ).find( 'a[href="#chart-display"]' ).tab( 'show' ); + // render the tab content + this._render_dataControl(); + this._render_chartControls(); + this._render_chartDisplay(); + + // set up behaviours + + // auto render if given both x, y column choices + var config = this.model.get( 'config' ); + if( this.model.id && _.isFinite( config.xColumn ) && _.isFinite( config.yColumn ) ){ + this.renderChart(); + } return this; }, - _render_dataControl : function(){ - // controls for which columns are used to plot datapoints (and ids/additional info to attach if desired) - var dataset = this.dataset; + /** controls for which columns are used to plot datapoints (and ids/additional info to attach if desired) */ + _render_dataControl : function( $where ){ + //TODO: better handling of missing column names, column types + $where = $where || this.$el; + var editor = this, + dataset = this.dataset, + column_names = dataset.metadata_column_names || [], + config = this.model.get( 'config' ); //console.log( 'metadata_column_types:', this.dataset.metadata_column_types ); //console.log( 'metadata_column_names:', this.dataset.metadata_column_names ); - var allColumns = _.map( dataset.metadata_column_types, function( type, i ){ - var column = { index: i, type: type, name: ( 'column ' + ( i + 1 ) ) }; - if( dataset.metadata_column_names && dataset.metadata_column_names[ i ] ){ - column.name = dataset.metadata_column_names[ i ]; - } - return column; - }); - var numericColumns = _.filter( allColumns, function( column, i ){ - return ( ( column.type === 'int' ) || ( column.type === 'float' ) ); - }); - if( numericColumns < 2 ){ +//TODO: to peek based control + var numericColumns = [], + allColumns = _.map( dataset.metadata_column_types, function( type, i ){ + // save column data for select rendering, adding metadata name if available in dataset + var column = { index: i, type: type, name: ( column_names[ i ] || ( 'column ' + ( i + 1 )) ) }; + // also add column to numerics if numeric type + if( ( column.type === 'int' ) || ( column.type === 'float' ) ){ + numericColumns.push( column ); + } + return column; + }); + if( numericColumns.length < 2 ){ numericColumns = allColumns; } //console.log( 'allColumns:', allColumns ); //console.log( 'numericColumns:', numericColumns ); // render the html - var $dataControl = this.$el.find( '.tab-pane#data-control' ); + var $dataControl = $where.find( '.tab-pane#data-control' ); $dataControl.html( ScatterplotConfigEditor.templates.dataControl({ allColumns : allColumns, numericColumns : numericColumns })); - // preset to column selectors if they were passed in the config in the query string - $dataControl.find( '[name="xColumn"]' ).val( this.plotView.config.xColumn || numericColumns[0].index ); - $dataControl.find( '[name="yColumn"]' ).val( this.plotView.config.yColumn || numericColumns[1].index ); - if( this.plotView.config.idColumn !== undefined ){ +//TODO: column selection boilerplate + // preset to column selectors if they were passed in the config in the query string; set up events + var newConfig = { + xColumn : ( _.isFinite( config.xColumn ) )? ( config.xColumn ): ( numericColumns[0].index ), + yColumn : ( _.isFinite( config.yColumn ) )? ( config.yColumn ): ( numericColumns[1].index ), + idColumn : allColumns[0].index + }; + // use an idColumn from the config or attempt to get one different from the numeric + if( _.isFinite( config.idColumn ) ){ + newConfig.idColumn = config.idColumn; + } else { + if( allColumns.length > 2 ){ + var uniqueCol = _.find( allColumns, function( column, i ){ + return i !== newConfig.xColumn && i !== newConfig.yColumn; + }); + newConfig.idColumn = uniqueCol.index; + } + } + config = this.model.set( 'config', newConfig, { silent: true }).get( 'config' ); + + $dataControl.find( '[name="xColumn"]' ).val( config.xColumn ).on( 'change', function(){ + editor.model.set( 'config', { xColumn: Number( $( this ).val() ) }); + }); + $dataControl.find( '[name="yColumn"]' ).val( config.yColumn ).on( 'change', function(){ + editor.model.set( 'config', { yColumn: Number( $( this ).val() ) }); + }); + $dataControl.find( 'select[name="idColumn"]' ).val( config.idColumn ).on( 'change', function(){ + editor.model.set( 'config', { idColumn: Number( $( this ).val() ) }); + }); + if( config.idColumn !== undefined ){ $dataControl.find( '#include-id-checkbox' ).prop( 'checked', true ).trigger( 'change' ); - $dataControl.find( 'select[name="idColumn"]' ).val( this.plotView.config.idColumn ); } + $dataControl.find( '[title]' ).tooltip(); return $dataControl; }, - _render_chartControls : function( $chartControls ){ - // tab content to control how the chart is rendered (data glyph size, chart size, etc.) - $chartControls.html( ScatterplotConfigEditor.templates.chartControl( this.plotView.config ) ); + /** tab content to control how the chart is rendered (data glyph size, chart size, etc.) */ + _render_chartControls : function( $where ){ + $where = $where || this.$el; + var editor = this, + config = this.model.get( 'config' ), + $chartControls = $where.find( '#chart-control' ); + + // ---- skeleton/form for controls + $chartControls.html( ScatterplotConfigEditor.templates.chartControl( config ) ); //console.debug( '$chartControl:', $chartControls ); - // set up behaviours, js on sliders - //console.debug( 'numeric sliders:', $chartControls.find( '.numeric-slider-input' ) ); - // what to do when the slider changes: update display and update chartConfig - var view = this, - // limits for controls (by control/chartConfig id) - //TODO: move into TwoVarScatterplot - controlRanges = { + // ---- slider controls + // limits for controls (by control/chartConfig id) + //TODO: as class attribute + var controlRanges = { 'datapointSize' : { min: 2, max: 10, step: 1 }, 'width' : { min: 200, max: 800, step: 20 }, 'height' : { min: 200, max: 800, step: 20 } }; function onSliderChange(){ - var $this = $( this ); - $this.siblings( '.slider-output' ).text( $this.slider( 'value' ) ); + // set the model config when changed and update the slider output text + var $this = $( this ), + //note: returns a number nicely enough + newVal = $this.slider( 'value' ); + // parent of slide event target has html5 attr data-config-key + editor.model.set( 'config', _.object([[ $this.parent().data( 'config-key' ), newVal ]]) ); + $this.siblings( '.slider-output' ).text( newVal ); } + + //console.debug( 'numeric sliders:', $chartControls.find( '.numeric-slider-input' ) ); $chartControls.find( '.numeric-slider-input' ).each( function(){ + // set up the slider with control ranges, change event; set output text to initial value var $this = $( this ), configKey = $this.attr( 'data-config-key' ), sliderSettings = _.extend( controlRanges[ configKey ], { - value : view.plotView.config[ configKey ], + value : config[ configKey ], change : onSliderChange, slide : onSliderChange }); //console.debug( configKey + ' slider settings:', sliderSettings ); $this.find( '.slider' ).slider( sliderSettings ); + $this.children( '.slider-output' ).text( config[ configKey ] ); }); -//TODO: to more common area (like render)? + + // ---- axes labels + var columnNames = this.dataset.metadata_column_names || []; + var xLabel = config.xLabel || columnNames[ config.xColumn ] || 'X'; + var yLabel = config.yLabel || columnNames[ config.yColumn ] || 'Y'; // set label inputs to current x, y metadata_column_names (if any) - if( this.dataset.metadata_column_names ){ - //var colNames = this.dataset.metadata_column_names; - //$chartControls.find( 'input[name="X-axis-label"]' ).val( colNames ); - //$chartControls.find( 'input[name="Y-axis-label"]' ).val( colNames ); -//TODO: on change of x, y data controls - } + $chartControls.find( 'input[name="X-axis-label"]' ).val( xLabel ) + .on( 'change', function(){ + editor.model.set( 'config', { xLabel: $( this ).val() }); + }); + $chartControls.find( 'input[name="Y-axis-label"]' ).val( yLabel ) + .on( 'change', function(){ + editor.model.set( 'config', { yLabel: $( this ).val() }); + }); //console.debug( '$chartControls:', $chartControls ); + $chartControls.find( '[title]' ).tooltip(); return $chartControls; }, - _render_chartDisplay : function(){ - // render the tab content where the chart is displayed (but not the chart itself) - var $chartDisplay = this.$el.find( '.tab-pane#chart-display' ); - this.plotView.setElement( $chartDisplay ); - this.plotView.render(); + /** render the tab content where the chart is displayed (but not the chart itself) */ + _render_chartDisplay : function( $where ){ + $where = $where || this.$el; + var $chartDisplay = $where.find( '.tab-pane#chart-display' ); + this.display.setElement( $chartDisplay ); + this.display.render(); + + $chartDisplay.find( '[title]' ).tooltip(); return $chartDisplay; }, @@ -179,7 +229,22 @@ var ScatterplotConfigEditor = Backbone.View.extend( LoggableMixin ).extend({ events : { 'change #include-id-checkbox' : 'toggleThirdColumnSelector', 'click #data-control .render-button' : 'renderChart', - 'click #chart-control .render-button' : 'renderChart' + 'click #chart-control .render-button' : 'renderChart', + 'click .save-btn' : 'saveVisualization', + //'click .copy-btn' : function(e){ this.model.save(); } + }, + + saveVisualization : function(){ + var editor = this; + this.model.save() + .fail( function( xhr, status, message ){ + console.error( xhr, status, message ); + editor.trigger( 'save:error', view ); + alert( 'Error loading data:\n' + xhr.responseText ); + }) + .then( function(){ + editor.render(); + }); }, toggleThirdColumnSelector : function(){ @@ -192,42 +257,9 @@ var ScatterplotConfigEditor = Backbone.View.extend( LoggableMixin ).extend({ //console.log( this + '.renderChart' ); // fetch the data, (re-)render the chart this.$el.find( '.nav li.disabled' ).removeClass( 'disabled' ); - this.updateConfigWithDataSettings(); - this.updateConfigWithChartSettings(); this.$el.find( 'ul.nav' ).find( 'a[href="#chart-display"]' ).tab( 'show' ); - this.plotView.fetchData(); - //console.debug( this.plotView.$el ); - }, - - // ------------------------------------------------------------------------- GET DATA/CHART SETTINGS - updateConfigWithDataSettings : function(){ - // parse the column values for both indeces (for the data fetch) and names (for the chart) - var $dataControls = this.$el.find( '#data-control' ); - var settings = { - xColumn : Number( $dataControls.find( '[name="xColumn"]' ).val() ), - yColumn : Number( $dataControls.find( '[name="yColumn"]' ).val() ) - }; - if( $dataControls.find( '#include-id-checkbox' ).prop( 'checked' ) ){ - settings.idColumn = $dataControls.find( '[name="idColumn"]' ).val(); - } - //console.log( '\t data settings:', settings ); - return _.extend( this.plotView.config, settings ); - }, - - updateConfigWithChartSettings : function(){ - // gets the user-selected chartConfig from the chart settings panel - var plotView = this.plotView, - $chartControls = this.$el.find( '#chart-control' ); - // use a loop of config keys to get the form values for these sliders - [ 'datapointSize', 'width', 'height' ].forEach( function( v, i ){ - plotView.config[ v ] = $chartControls.find( '.numeric-slider-input[data-config-key="' + v + '"]' ) - .find( '.slider' ).slider( 'value' ); - }); - // update axes labels using chartSettings inputs (if not at defaults), otherwise the selects' colName - plotView.config.x.label = $chartControls.find( 'input[name="X-axis-label"]' ).val(); - plotView.config.y.label = $chartControls.find( 'input[name="Y-axis-label"]' ).val(); - //console.log( '\t chartSettings:', settings ); - return plotView.config; + this.display.fetchData(); + //console.debug( this.display.$el ); }, toString : function(){ diff --git a/config/plugins/visualizations/scatterplot/src/scatterplot-display.js b/config/plugins/visualizations/scatterplot/src/scatterplot-display.js index 3f6c1ab353e..ea9927430d4 100644 --- a/config/plugins/visualizations/scatterplot/src/scatterplot-display.js +++ b/config/plugins/visualizations/scatterplot/src/scatterplot-display.js @@ -6,242 +6,242 @@ * computing and displaying data stats * controls for pagination of data (if needed) */ -var ScatterplotView = Backbone.View.extend({ - //TODO: should be a view on visualization(revision) model - - defaults : { - metadata : { - dataLines : undefined - }, - - pagination : { - currPage : 0, - perPage : 3000 - }, - - width : 400, - height : 400, - - margin : { - top : 16, - right : 16, - bottom : 40, - left : 54 - }, - - x : { - ticks : 10, - label : 'X' - }, - y : { - ticks : 10, - label : 'Y' - }, - - datapointSize : 4, - animDuration : 500 - }, +var ScatterplotDisplay = Backbone.View.extend({ initialize : function( attributes ){ - this.config = _.extend( _.clone( this.defaults ), attributes.config || {}); + this.data = null, this.dataset = attributes.dataset; - //console.debug( this + '.config:', this.config ); + this.calcNumPages(); }, - updateConfig : function( newConfig ){ - //console.log( this + '.updateConfig:', newConfig ); - this.config = this.config || {}; - //TODO: validate here - _.extend( this.config, newConfig ); - //TODO: implement rerender flag + calcNumPages : function(){ + var config = this.model.get( 'config' ); + this.lineCount = this.dataset.metadata_data_lines, + this.numPages = ( this.lineCount )?( Math.ceil( this.lineCount / config.pagination.perPage ) ):( undefined ); + if( !this.lineCount || this.numPages === undefined ){ + console.warn( 'no data total found' ); + } }, fetchData : function(){ //TODO: doesn't work bc it's rendered in render()... this.showLoadingIndicator( 'getting data' ); //console.debug( 'currPage', this.config.pagination.currPage ); - var view = this; + var view = this, + config = this.model.get( 'config' ), //TODO: very tied to datasets - should be generalized eventually xhr = jQuery.getJSON( '/api/datasets/' + this.dataset.id, { data_type : 'raw_data', provider : 'dataset-column', - limit : this.config.pagination.perPage, - offset : ( this.config.pagination.currPage * this.config.pagination.perPage ) + limit : config.pagination.perPage, + offset : ( config.pagination.currPage * config.pagination.perPage ) }); xhr.done( function( data ){ - view.renderData( data.data ); + view.data = data.data; + view.trigger( 'data:fetched', view ); + view.renderData(); }); xhr.fail( function( xhr, status, message ){ - alert( 'Error loading data:\n' + xhr.responseText ); console.error( xhr, status, message ); - }); - xhr.always( function(){ - view.hideLoadingIndicator(); + view.trigger( 'data:error', view ); + alert( 'Error loading data:\n' + xhr.responseText ); }); return xhr; }, - render : function( data ){ - this.$el.addClass( 'scatterplot-display' ).html([ - '
    ', + showLoadingIndicator : function(){ + // display the loading indicator over the tab panels if hidden, update message (if passed) + this.$el.find( '.scatterplot-data-info' ).html([ '
    ', '', - '', + 'loading...', + '
    ' + ].join( '' )); + }, + + template : function(){ + var html = [ + '
    ', + '
    ', + '
    ', + '
    ', + '

    ', + '', + '', + '
    ', '
    ', '', //TODO: id '
    ' - ].join( '' )); - this.$el.children().hide(); + ].join( '' ); + return html; + }, - if( data ){ - this.renderData( data ); + render : function(){ + this.$el.addClass( 'scatterplot-display' ).html( this.template() ); + if( this.data ){ + this.renderData(); } return this; }, - showLoadingIndicator : function( message, speed ){ - // display the loading indicator over the tab panels if hidden, update message (if passed) -//TODO: move loading indicator into data-info-text - message = message || ''; - speed = speed || 'fast'; - var $indicator = this.$el.find( '.loading-indicator' ); + renderData : function(){ + this.renderLeftControls(); + this.renderRightControls(); + this.renderPlot( this.data ); + this.getStats(); + }, - if( message ){ $indicator.find( '.loading-indicator-message' ).text( message ); } - if( !$indicator.is( ':visible' ) ){ - this.toggleStats( false ); - $indicator.css({ left: ( this.config.width / 2 ), top: this.config.height / 2 }).show(); + renderLeftControls : function(){ + if( this.lineCount ){ + this.$el.find( '.controls .left' ).empty().append( this.renderPagination() ); + } else { + this.$el.find( '.controls .left' ).empty().append( this.renderPrevNext() ); } + return this; }, - hideLoadingIndicator : function( speed ){ - speed = speed || 'fast'; - this.$el.find( '.loading-indicator' ).hide(); - }, - - renderData : function( data ){ - this.$el.find( '.controls' ).empty().append( this.renderControls( data ) ).show(); - this.renderPlot( data ); - this.getStats( data ); - }, - - renderControls : function( data ){ + renderRightControls : function(){ var view = this; - var $left = $( '
    ' ), - $right = $( '
    ' ); - - $left.append([ - this.renderPrevNext( data ), - this.renderPagination( data ) - ]); - $right.append([ - this.renderLineInfo( data ), - $( '' ).addClass( 'stats-toggle-btn' ) - .click( function(){ - view.toggleStats(); - }), - $( '' ).addClass( 'rerender-btn' ) - .click( function(){ - view.renderPlot( data ); - }) - ]); - return [ $left, $right ]; + this.setLineInfo( this.data ); + // clear prev. handlers due to closure around data + this.$el.find( '.stats-toggle-btn' ) + .off().click( function(){ + view.toggleStats(); + }); + this.$el.find( '.rerender-btn' ) + .off().click( function(){ + view.resetZoom(); + view.renderPlot( this.data ); + }); }, - renderLineInfo : function( data ){ - var totalLines = this.dataset.metadata_data_lines || 'an unknown number of', - lineStart = ( this.config.pagination.currPage * this.config.pagination.perPage ), - lineEnd = lineStart + data.length; - return $( '

    ' ).addClass( 'scatterplot-data-info' ) - .text([ 'Displaying lines', lineStart + 1, 'to', lineEnd, 'of', totalLines, 'lines' ].join( ' ' )); - }, - - renderPrevNext : function( data ){ - // this is cra-zazy - if( !data - || ( this.config.pagination.currPage === 0 && data.length < this.config.pagination.perPage ) ){ return null; } - - function makePage$Li( text ){ - return $([ '

  • ', text, '
  • ' ].join( '' )); - } -//TODO: cache numPages/numLines in config + /** render and show the d3 plot into the svg node of the view */ + renderPlot : function(){ var view = this, - dataLines = this.dataset.metadata_data_lines, - numPages = ( dataLines )?( Math.ceil( dataLines / this.config.pagination.perPage ) ):( undefined ); - //console.debug( 'data:', this.dataset.metadata_data_lines, 'numPages:', numPages ); + $svg = this.$el.find( 'svg' ); + // turn off stats, clear previous svg, and make it visible + this.toggleStats( false ); + $svg.off().empty().show() + // set up listeners for events from plot + .on( 'zoom.scatterplot', function( ev, zoom ){ + //TODO: possibly throttle this + //console.debug( 'zoom.scatterplot', zoom.scale, zoom.translate ); + view.model.set( 'config', zoom ); + }); + //TODO: may not be necessary to off/on this more than the initial on + // call the sep. d3 function to generate the plot + scatterplot( $svg.get( 0 ), this.model.get( 'config' ), this.data ); + }, - // prev next buttons - var $prev = makePage$Li( 'Prev' ).click( function(){ - if( view.config.pagination.currPage > 0 ){ - view.config.pagination.currPage -= 1; - view.fetchData(); - } - }), - $next = makePage$Li( 'Next' ).click( function(){ - if( !numPages || view.config.pagination.currPage < ( numPages - 1 ) ){ - view.config.pagination.currPage += 1; - view.fetchData(); - } - }), - $prevNextList = $( '
      ' ).addClass( 'pagination data-prev-next' ) - .append([ $prev, $next ]); + setLineInfo : function( data, contents ){ + if( data ){ + var config = this.model.get( 'config' ), + totalLines = this.lineCount || 'an unknown total', + lineStart = config.pagination.currPage * config.pagination.perPage, + lineEnd = lineStart + data.length; + this.$el.find( '.controls p.scatterplot-data-info' ) + .text([ lineStart + 1, 'to', lineEnd, 'of', totalLines ].join( ' ' )); + } else { + this.$el.find( '.controls p.scatterplot-data-info' ).html( contents || '' ); + } - if( view.config.pagination.currPage === 0 ){ + return this; + }, + + resetZoom : function( scale, translate ){ + scale = ( scale !== undefined )?( scale ):( 1 ); + translate = ( translate !== undefined )?( translate ):( [ 0, 0 ] ); + this.model.set( 'config', { scale: scale, translate: translate } ); + return this; + }, + + // ------------------------------------------------------------------------ data pagination +//TODO: to pagination control + goToPage : function( page ){ + var pagination = this.model.get( 'config' ).pagination; + //console.debug( 'goToPage', page, pagination, this.numPages ); + if( page <= 0 ){ page = 0; } + if( this.numPages && page >= this.numPages ){ page = this.numPages - 1; } + if( page === pagination.currPage ){ return this; } + + //console.debug( '\t going to page ' + page ) + pagination.currPage = page; + this.model.set( 'config', { pagination: pagination }); + this.resetZoom(); + this.fetchData(); + return this; + }, + + nextPage : function(){ + var currPage = this.model.get( 'config' ).pagination.currPage; + return this.goToPage( currPage + 1 ); + }, + + prevPage : function(){ + var currPage = this.model.get( 'config' ).pagination.currPage; + return this.goToPage( currPage - 1 ); + }, + + /** render previous and next pagination buttons */ + renderPrevNext : function(){ + var config = this.model.get( 'config' ); + // if there's no data or there's less than one page of data - return null + if( !this.data ){ return null; } + if( config.pagination.currPage === 0 && this.data.length < config.pagination.perPage ){ return null; } + + var view = this, + $prev = $( '
    • Prev
    • ' ) + .click( function(){ view.prevPage(); }), + $next = $( '
    • Next
    • ' ) + .click( function(){ view.nextPage(); }); + + // disable if it either end + if( config.pagination.currPage === 0 ){ $prev.addClass( 'disabled' ); } - if( numPages && view.config.pagination.currPage === ( numPages - 1 ) ){ + if( this.numPages && config.pagination.currPage === ( this.numPages - 1 ) ){ $next.addClass( 'disabled' ); } - return $prevNextList; + return $( '
        ' ).addClass( 'pagination data-prev-next' ).append([ $prev, $next ]); }, - renderPagination : function( data ){ - // this is cra-zazy - if( !data - || ( this.config.pagination.currPage === 0 && data.length < this.config.pagination.perPage ) ){ return null; } + /** render page links for each possible page (if we can) */ + renderPagination : function(){ + var config = this.model.get( 'config' ); + // if there's no data, no page count, or there's less than one page of data - return null + if( !this.data ){ return null; } + if( !this.numPages ){ return null; } + if( config.pagination.currPage === 0 && this.data.length < config.pagination.perPage ){ return null; } - function makePage$Li( text ){ - return $([ '
      • ', text, '
      • ' ].join( '' )); - } -//TODO: cache numPages/numLines in config var view = this, - dataLines = this.dataset.metadata_data_lines, - numPages = ( dataLines )?( Math.ceil( dataLines / this.config.pagination.perPage ) ):( undefined ); - //console.debug( 'data:', this.dataset.metadata_data_lines, 'numPages:', numPages ); + $pagesList = $( '
          ' ).addClass( 'pagination data-pages' ); + pageNumClick = function( ev ){ + view.goToPage( $( this ).data( 'page' ) ); + }; - // page numbers (as separate control) - //var $paginationContainer = $( '
          ' ).addClass( 'pagination-container' ), - var $pagesList = $( '
            ' ).addClass( 'pagination data-pages' ); - function pageNumClick( ev ){ - view.config.pagination.currPage = $( this ).data( 'page' ); - view.fetchData(); - } - for( var i=0; i', i + 1, '' ].join( '' )) + .attr( 'data-page', i ).click( pageNumClick ); + // highlight the current page + if( i === config.pagination.currPage ){ $pageLi.addClass( 'active' ); } $pagesList.append( $pageLi ); } - // placing the pages list in an extra container allows us to set a max-width and scroll if overflow - //$paginationContainer.append( $pagesList ); - //return $paginationContainer; return $pagesList; }, - renderPlot : function( data ){ - this.toggleStats( false ); - var $svg = this.$el.find( 'svg' ); - $svg.off().empty().show(); - scatterplot( $svg.get( 0 ), this.config, data ); - }, - - getStats : function( data ){ - var view = this; + // ------------------------------------------------------------------------ statistics display + /** create a webworker to calc stats for data given */ + getStats : function(){ + if( !this.data ){ return; } + var view = this, + config = this.model.get( 'config' ), meanWorker = new Worker( '/plugins/visualizations/scatterplot/static/worker-stats.js' ); meanWorker.postMessage({ - data : data, - keys : [ this.config.xColumn, this.config.yColumn ] + data : this.data, + keys : [ config.xColumn, config.yColumn ] }); meanWorker.onerror = function( event ){ meanWorker.terminate(); @@ -254,9 +254,9 @@ var ScatterplotView = Backbone.View.extend({ renderStats : function( stats, error ){ //console.debug( 'renderStats:', stats, error ); //console.debug( JSON.stringify( stats, null, ' ' ) ); - var $statsTable = this.$el.find( '.stats-display' ); - - var xLabel = this.config.x.label, yLabel = this.config.y.label, + var config = this.model.get( 'config' ), + $statsTable = this.$el.find( '.stats-display' ), + xLabel = config.x.label, yLabel = config.y.label, $table = $( '' ).addClass( 'table' ) .append([ '' ].join( '' )) .append( _.map( stats, function( stat, key ){ diff --git a/config/plugins/visualizations/scatterplot/src/scatterplot.js b/config/plugins/visualizations/scatterplot/src/scatterplot.js index e0d4fd05d45..e8c0660b5d1 100644 --- a/config/plugins/visualizations/scatterplot/src/scatterplot.js +++ b/config/plugins/visualizations/scatterplot/src/scatterplot.js @@ -46,8 +46,9 @@ function scatterplot( renderTo, config, data ){ var zoom = d3.behavior.zoom() .x( interpolaterFns.x ) .y( interpolaterFns.y ) - .scaleExtent([ 1, 10 ]); -//TODO: you can prog. set the zoom and pan with zoom.scale( val ) and zoom.translate([ x, y ])... + .scaleExtent([ 1, 30 ]) + .scale( config.scale || 1 ) + .translate( config.translate || [ 0, 0 ] ); //console.debug( renderTo ); var svg = d3.select( renderTo ) @@ -99,7 +100,7 @@ function scatterplot( renderTo, config, data ){ //console.log( 'axis.y.g:', axis.y.g ); // ................................ axis labels - var padding = 4; + var padding = 6; // x-axis label axis.x.label = svg.append( 'text' ) .attr( 'class', 'axis-label' ) @@ -176,18 +177,17 @@ function scatterplot( renderTo, config, data ){ .enter().append( 'svg:circle' ) .classed( "glyph", true ) .attr( "cx", function( d, i ){ return interpolaterFns.x( getX( d, i ) ); }) - // give them a 'entry' position and style - .attr( "cy", config.height ) + .attr( "cy", function( d, i ){ return interpolaterFns.y( getY( d, i ) ); }) .attr( "r", 0 ); // for all EXISTING glyphs and those that need to be added: transition anim to final state datapoints.transition().duration( config.animDuration ) - .attr( "cy", function( d, i ){ return interpolaterFns.y( getY( d, i ) ); }) .attr( "r", config.datapointSize ); //console.log( 'datapoints:', datapoints ); function _redrawDatapointsClipped(){ return datapoints + //TODO: interpolates twice .attr( "cx", function( d, i ){ return interpolaterFns.x( getX( d, i ) ); }) .attr( "cy", function( d, i ){ return interpolaterFns.y( getY( d, i ) ); }) .style( 'display', 'block' ) @@ -200,29 +200,34 @@ function scatterplot( renderTo, config, data ){ return false; }).style( 'display', 'none' ); } + _redrawDatapointsClipped(); // .................................................................... behaviors function zoomed( scale, translateX, translateY ){ - //console.debug( 'zoom', this, scale, translateX, translateY, arguments ); + //console.debug( 'zoom', this, zoom.scale(), zoom.translate() ); + // re-render axis, grid, and datapoints + $( '.chart-info-box' ).remove(); axis.redraw(); _redrawDatapointsClipped(); grid = renderGrid(); - $( '.chart-info-box' ).remove(); - $( svg.node() ).trigger( 'zoom.scatterplot', [] ); + + $( svg.node() ).trigger( 'zoom.scatterplot', { + scale : zoom.scale(), + translate : zoom.translate() + }); } //TODO: programmatically set zoom/pan and save in config //TODO: set pan/zoom limits zoom.on( "zoom", zoomed ); - function infoBox( top, left, d ){ // create an abs pos. element containing datapoint data (d) near the point (top, left) // with added padding to clear the mouse pointer left += 8; return $([ '
            ', - (( config.idColumn )?( '
            ' + d[ config.idColumn ] + '
            ' ):( '' )), + (( config.idColumn !== undefined )?( '
            ' + d[ config.idColumn ] + '
            ' ):( '' )), '
            ', getX( d ), '
            ', '
            ', getY( d ), '
            ', '
            ' diff --git a/config/plugins/visualizations/scatterplot/static/scatterplot-edit.js b/config/plugins/visualizations/scatterplot/static/scatterplot-edit.js index b8b804c3a75..c591a9317cf 100644 --- a/config/plugins/visualizations/scatterplot/static/scatterplot-edit.js +++ b/config/plugins/visualizations/scatterplot/static/scatterplot-edit.js @@ -1 +1 @@ -function scatterplot(a,b,c){function d(){var a={v:{},h:{}};return a.v.lines=p.selectAll("line.v-grid-line").data(m.x.ticks(q.x.fn.ticks()[0])),a.v.lines.enter().append("svg:line").classed("grid-line v-grid-line",!0),a.v.lines.attr("x1",m.x).attr("x2",m.x).attr("y1",0).attr("y2",b.height),a.v.lines.exit().remove(),a.h.lines=p.selectAll("line.h-grid-line").data(m.y.ticks(q.y.fn.ticks()[0])),a.h.lines.enter().append("svg:line").classed("grid-line h-grid-line",!0),a.h.lines.attr("x1",0).attr("x2",b.width).attr("y1",m.y).attr("y2",m.y),a.h.lines.exit().remove(),a}function e(){return t.attr("cx",function(a,b){return m.x(j(a,b))}).attr("cy",function(a,b){return m.y(k(a,b))}).style("display","block").filter(function(){var a=d3.select(this).attr("cx"),c=d3.select(this).attr("cy");return 0>a||a>b.width?!0:0>c||c>b.height?!0:!1}).style("display","none")}function f(){q.redraw(),e(),s=d(),$(".chart-info-box").remove(),$(o.node()).trigger("zoom.scatterplot",[])}function g(a,c,d){return c+=8,$(['
            ',b.idColumn?"
            "+d[b.idColumn]+"
            ":"","
            ",j(d),"
            ","
            ",k(d),"
            ","
            "].join("")).css({top:a,left:c,"z-index":2})}var h=function(a,b){return"translate("+a+","+b+")"},i=function(a,b,c){return"rotate("+a+","+b+","+c+")"},j=function(a){return a[b.xColumn]},k=function(a){return a[b.yColumn]},l={x:{extent:d3.extent(c,j)},y:{extent:d3.extent(c,k)}},m={x:d3.scale.linear().domain(l.x.extent).range([0,b.width]),y:d3.scale.linear().domain(l.y.extent).range([b.height,0])},n=d3.behavior.zoom().x(m.x).y(m.y).scaleExtent([1,10]),o=d3.select(a).attr("class","scatterplot").attr("width","100%").attr("height",b.height+(b.margin.top+b.margin.bottom)),p=o.append("g").attr("class","content").attr("transform",h(b.margin.left,b.margin.top)).call(n);p.append("rect").attr("class","zoom-rect").attr("width",b.width).attr("height",b.height).style("fill","transparent");var q={x:{},y:{}};q.x.fn=d3.svg.axis().orient("bottom").scale(m.x).ticks(b.x.ticks).tickFormat(d3.format("s")),q.y.fn=d3.svg.axis().orient("left").scale(m.y).ticks(b.y.ticks).tickFormat(d3.format("s")),q.x.g=p.append("g").attr("class","x axis").attr("transform",h(0,b.height)).call(q.x.fn),q.y.g=p.append("g").attr("class","y axis").call(q.y.fn);var r=4;q.x.label=o.append("text").attr("class","axis-label").text(b.x.label).attr("text-anchor","middle").attr("dominant-baseline","text-after-edge").attr("x",b.width/2+b.margin.left).attr("y",b.height+b.margin.bottom+b.margin.top-r),q.y.label=o.append("text").attr("class","axis-label").text(b.y.label).attr("text-anchor","middle").attr("dominant-baseline","text-before-edge").attr("x",r).attr("y",b.height/2).attr("transform",i(-90,r,b.height/2)),q.redraw=function(){o.select(".x.axis").call(q.x.fn),o.select(".y.axis").call(q.y.fn)};var s=d(),t=p.selectAll(".glyph").data(c).enter().append("svg:circle").classed("glyph",!0).attr("cx",function(a,b){return m.x(j(a,b))}).attr("cy",b.height).attr("r",0);t.transition().duration(b.animDuration).attr("cy",function(a,b){return m.y(k(a,b))}).attr("r",b.datapointSize),n.on("zoom",f),t.on("mouseover",function(a,c){var d=d3.select(this);d.style("fill","red").style("fill-opacity",1),p.append("line").attr("stroke","red").attr("stroke-width",1).attr("x1",d.attr("cx")-b.datapointSize).attr("y1",d.attr("cy")).attr("x2",0).attr("y2",d.attr("cy")).classed("hoverline",!0),d.attr("cy")= 1.0.0"],c=this.merge(c,a.helpers),e=e||{};var f,g="",h="function",i=this.escapeExpression;return g+='

            \n Use the following controls to how the chart is displayed.\n The slide controls can be moved by the mouse or, if the \'handle\' is in focus, your keyboard\'s arrow keys.\n Move the focus between controls by using the tab or shift+tab keys on your keyboard.\n Use the \'Draw\' button to render (or re-render) the chart with the current settings.\n

            \n\n
            \n \n
            ',(f=c.datapointSize)?f=f.call(b,{hash:{},data:e}):(f=b.datapointSize,f=typeof f===h?f.apply(b):f),g+=i(f)+'
            \n
            \n

            \n Size of the graphic representation of each data point\n

            \n
            \n\n
            \n \n
            ',(f=c.width)?f=f.call(b,{hash:{},data:e}):(f=b.width,f=typeof f===h?f.apply(b):f),g+=i(f)+'
            \n
            \n

            \n (not including chart margins and axes)\n

            \n
            \n\n
            \n \n
            ',(f=c.height)?f=f.call(b,{hash:{},data:e}):(f=b.height,f=typeof f===h?f.apply(b):f),g+=i(f)+'
            \n
            \n

            \n (not including chart margins and axes)\n

            \n
            \n\n
            \n \n \n

            \n
            \n\n
            \n \n \n

            \n
            \n\n\n'}),this.scatterplot.datacontrol=Handlebars.template(function(a,b,c,d,e){function f(a,b){var d,e="";return e+='\n \n "}function g(){return'checked="true"'}this.compilerInfo=[4,">= 1.0.0"],c=this.merge(c,a.helpers),e=e||{};var h,i="",j="function",k=this.escapeExpression,l=this;return i+='

            \n Use the following controls to change the data used by the chart.\n Use the \'Draw\' button to render (or re-render) the chart with the current settings.\n

            \n\n\n
            \n \n \n
            \n
            \n \n \n
            \n\n\n
            \n \n \n

            \n These will be displayed (along with the x and y values) when you hover over\n a data point.\n

            \n
            \n\n\n\n\n\n\n'}),this.scatterplot.editor=Handlebars.template(function(a,b,c,d,e){this.compilerInfo=[4,">= 1.0.0"],c=this.merge(c,a.helpers),e=e||{};var f="";return f+='
            \n \n \n\n \n
            \n \n
            \n \n
            \n \n \n
            \n \n
            \n\n \n
            \n\n
            \n
            \n'});var ScatterplotConfigEditor=Backbone.View.extend(LoggableMixin).extend({className:"scatterplot-control-form",initialize:function(a){if(this.model||(this.model=new Visualization({type:"scatterplot"})),console.log(this+".initialize, attributes:",a),!a||!a.dataset)throw new Error("ScatterplotConfigEditor requires a dataset");this.dataset=a.dataset,console.log("dataset:",this.dataset),this.plotView=new ScatterplotView({dataset:a.dataset,model:this.model})},render:function(){return this.$el.append(ScatterplotConfigEditor.templates.mainLayout({})),this.$el.find("#data-control").append(this._render_dataControl()),this._render_chartControls(this.$el.find("#chart-control")),this._render_chartDisplay(),this.$el.find("[title]").tooltip(),this},_render_dataControl:function(){var a=this.dataset,b=_.map(a.metadata_column_types,function(b,c){var d={index:c,type:b,name:"column "+(c+1)};return a.metadata_column_names&&a.metadata_column_names[c]&&(d.name=a.metadata_column_names[c]),d}),c=_.filter(b,function(a){return"int"===a.type||"float"===a.type});2>c&&(c=b);var d=this.$el.find(".tab-pane#data-control");return d.html(ScatterplotConfigEditor.templates.dataControl({allColumns:b,numericColumns:c})),d.find('[name="xColumn"]').val(this.plotView.config.xColumn||c[0].index),d.find('[name="yColumn"]').val(this.plotView.config.yColumn||c[1].index),void 0!==this.plotView.config.idColumn&&(d.find("#include-id-checkbox").prop("checked",!0).trigger("change"),d.find('select[name="idColumn"]').val(this.plotView.config.idColumn)),d},_render_chartControls:function(a){function b(){var a=$(this);a.siblings(".slider-output").text(a.slider("value"))}a.html(ScatterplotConfigEditor.templates.chartControl(this.plotView.config));var c=this,d={datapointSize:{min:2,max:10,step:1},width:{min:200,max:800,step:20},height:{min:200,max:800,step:20}};return a.find(".numeric-slider-input").each(function(){var a=$(this),e=a.attr("data-config-key"),f=_.extend(d[e],{value:c.plotView.config[e],change:b,slide:b});a.find(".slider").slider(f)}),this.dataset.metadata_column_names,a},_render_chartDisplay:function(){var a=this.$el.find(".tab-pane#chart-display");return this.plotView.setElement(a),this.plotView.render(),a},events:{"change #include-id-checkbox":"toggleThirdColumnSelector","click #data-control .render-button":"renderChart","click #chart-control .render-button":"renderChart"},toggleThirdColumnSelector:function(){this.$el.find('select[name="idColumn"]').parent().toggle()},renderChart:function(){this.$el.find(".nav li.disabled").removeClass("disabled"),this.updateConfigWithDataSettings(),this.updateConfigWithChartSettings(),this.$el.find("ul.nav").find('a[href="#chart-display"]').tab("show"),this.plotView.fetchData()},updateConfigWithDataSettings:function(){var a=this.$el.find("#data-control"),b={xColumn:Number(a.find('[name="xColumn"]').val()),yColumn:Number(a.find('[name="yColumn"]').val())};return a.find("#include-id-checkbox").prop("checked")&&(b.idColumn=a.find('[name="idColumn"]').val()),_.extend(this.plotView.config,b)},updateConfigWithChartSettings:function(){var a=this.plotView,b=this.$el.find("#chart-control");return["datapointSize","width","height"].forEach(function(c){a.config[c]=b.find('.numeric-slider-input[data-config-key="'+c+'"]').find(".slider").slider("value")}),a.config.x.label=b.find('input[name="X-axis-label"]').val(),a.config.y.label=b.find('input[name="Y-axis-label"]').val(),a.config},toString:function(){return"ScatterplotConfigEditor("+(this.dataset?this.dataset.id:"")+")"}});ScatterplotConfigEditor.templates={mainLayout:scatterplot.editor,dataControl:scatterplot.datacontrol,chartControl:scatterplot.chartcontrol};var ScatterplotView=Backbone.View.extend({defaults:{metadata:{dataLines:void 0},pagination:{currPage:0,perPage:3e3},width:400,height:400,margin:{top:16,right:16,bottom:40,left:54},x:{ticks:10,label:"X"},y:{ticks:10,label:"Y"},datapointSize:4,animDuration:500},initialize:function(a){this.config=_.extend(_.clone(this.defaults),a.config||{}),this.dataset=a.dataset},updateConfig:function(a){this.config=this.config||{},_.extend(this.config,a)},fetchData:function(){this.showLoadingIndicator("getting data");var a=this;return xhr=jQuery.getJSON("/api/datasets/"+this.dataset.id,{data_type:"raw_data",provider:"dataset-column",limit:this.config.pagination.perPage,offset:this.config.pagination.currPage*this.config.pagination.perPage}),xhr.done(function(b){a.renderData(b.data)}),xhr.fail(function(a,b,c){alert("Error loading data:\n"+a.responseText),console.error(a,b,c)}),xhr.always(function(){a.hideLoadingIndicator()}),xhr},render:function(a){return this.$el.addClass("scatterplot-display").html(['
            ','
            ','','',"
            ","",'
            '].join("")),this.$el.children().hide(),a&&this.renderData(a),this},showLoadingIndicator:function(a,b){a=a||"",b=b||"fast";var c=this.$el.find(".loading-indicator");a&&c.find(".loading-indicator-message").text(a),c.is(":visible")||(this.toggleStats(!1),c.css({left:this.config.width/2,top:this.config.height/2}).show())},hideLoadingIndicator:function(a){a=a||"fast",this.$el.find(".loading-indicator").hide()},renderData:function(a){this.$el.find(".controls").empty().append(this.renderControls(a)).show(),this.renderPlot(a),this.getStats(a)},renderControls:function(a){var b=this,c=$('
            '),d=$('
            ');return c.append([this.renderPrevNext(a),this.renderPagination(a)]),d.append([this.renderLineInfo(a),$("").addClass("stats-toggle-btn").click(function(){b.toggleStats()}),$("").addClass("rerender-btn").click(function(){b.renderPlot(a)})]),[c,d]},renderLineInfo:function(a){var b=this.dataset.metadata_data_lines||"an unknown number of",c=this.config.pagination.currPage*this.config.pagination.perPage,d=c+a.length;return $("

            ").addClass("scatterplot-data-info").text(["Displaying lines",c+1,"to",d,"of",b,"lines"].join(" "))},renderPrevNext:function(a){function b(a){return $(['

          • ',a,"
          • "].join(""))}if(!a||0===this.config.pagination.currPage&&a.length0&&(c.config.pagination.currPage-=1,c.fetchData())}),g=b("Next").click(function(){(!e||c.config.pagination.currPage").addClass("pagination data-prev-next").append([f,g]);return 0===c.config.pagination.currPage&&f.addClass("disabled"),e&&c.config.pagination.currPage===e-1&&g.addClass("disabled"),h},renderPagination:function(a){function b(a){return $(['
          • ',a,"
          • "].join(""))}function c(){d.config.pagination.currPage=$(this).data("page"),d.fetchData()}if(!a||0===this.config.pagination.currPage&&a.length").addClass("pagination data-pages"),h=0;f>h;h+=1){var i=b(h+1).attr("data-page",h).click(c);h===this.config.pagination.currPage&&i.addClass("active"),g.append(i)}return g},renderPlot:function(a){this.toggleStats(!1);var b=this.$el.find("svg");b.off().empty().show(),scatterplot(b.get(0),this.config,a)},getStats:function(a){var b=this;meanWorker=new Worker("/plugins/visualizations/scatterplot/static/worker-stats.js"),meanWorker.postMessage({data:a,keys:[this.config.xColumn,this.config.yColumn]}),meanWorker.onerror=function(){meanWorker.terminate()},meanWorker.onmessage=function(a){b.renderStats(a.data)}},renderStats:function(a){var b=this.$el.find(".stats-display"),c=this.config.x.label,d=this.config.y.label,e=$("
            ', xLabel, '', yLabel, '
            ").addClass("table").append([""].join("")).append(_.map(a,function(a,b){return $([""].join(""))}));b.empty().append(e)},toggleStats:function(a){var b=this.$el.find(".stats-display");a=void 0===a?b.is(":hidden"):a,a?(this.$el.find("svg").hide(),b.show(),this.$el.find(".controls .stats-toggle-btn").text("Plot")):(b.hide(),this.$el.find("svg").show(),this.$el.find(".controls .stats-toggle-btn").text("Stats"))},toString:function(){return"ScatterplotView()"}}),ScatterplotModel=Visualization.extend({defaults:{type:"scatterplot",config:{metadata:{dataLines:void 0},pagination:{currPage:0,perPage:3e3},width:400,height:400,margin:{top:16,right:16,bottom:40,left:54},x:{ticks:10,label:"X"},y:{ticks:10,label:"Y"},datapointSize:4,animDuration:500}}}); \ No newline at end of file +function scatterplot(a,b,c){function d(){var a={v:{},h:{}};return a.v.lines=p.selectAll("line.v-grid-line").data(m.x.ticks(q.x.fn.ticks()[0])),a.v.lines.enter().append("svg:line").classed("grid-line v-grid-line",!0),a.v.lines.attr("x1",m.x).attr("x2",m.x).attr("y1",0).attr("y2",b.height),a.v.lines.exit().remove(),a.h.lines=p.selectAll("line.h-grid-line").data(m.y.ticks(q.y.fn.ticks()[0])),a.h.lines.enter().append("svg:line").classed("grid-line h-grid-line",!0),a.h.lines.attr("x1",0).attr("x2",b.width).attr("y1",m.y).attr("y2",m.y),a.h.lines.exit().remove(),a}function e(){return t.attr("cx",function(a,b){return m.x(j(a,b))}).attr("cy",function(a,b){return m.y(k(a,b))}).style("display","block").filter(function(){var a=d3.select(this).attr("cx"),c=d3.select(this).attr("cy");return 0>a||a>b.width?!0:0>c||c>b.height?!0:!1}).style("display","none")}function f(){$(".chart-info-box").remove(),q.redraw(),e(),s=d(),$(o.node()).trigger("zoom.scatterplot",{scale:n.scale(),translate:n.translate()})}function g(a,c,d){return c+=8,$(['
            ',void 0!==b.idColumn?"
            "+d[b.idColumn]+"
            ":"","
            ",j(d),"
            ","
            ",k(d),"
            ","
            "].join("")).css({top:a,left:c,"z-index":2})}var h=function(a,b){return"translate("+a+","+b+")"},i=function(a,b,c){return"rotate("+a+","+b+","+c+")"},j=function(a){return a[b.xColumn]},k=function(a){return a[b.yColumn]},l={x:{extent:d3.extent(c,j)},y:{extent:d3.extent(c,k)}},m={x:d3.scale.linear().domain(l.x.extent).range([0,b.width]),y:d3.scale.linear().domain(l.y.extent).range([b.height,0])},n=d3.behavior.zoom().x(m.x).y(m.y).scaleExtent([1,30]).scale(b.scale||1).translate(b.translate||[0,0]),o=d3.select(a).attr("class","scatterplot").attr("width","100%").attr("height",b.height+(b.margin.top+b.margin.bottom)),p=o.append("g").attr("class","content").attr("transform",h(b.margin.left,b.margin.top)).call(n);p.append("rect").attr("class","zoom-rect").attr("width",b.width).attr("height",b.height).style("fill","transparent");var q={x:{},y:{}};q.x.fn=d3.svg.axis().orient("bottom").scale(m.x).ticks(b.x.ticks).tickFormat(d3.format("s")),q.y.fn=d3.svg.axis().orient("left").scale(m.y).ticks(b.y.ticks).tickFormat(d3.format("s")),q.x.g=p.append("g").attr("class","x axis").attr("transform",h(0,b.height)).call(q.x.fn),q.y.g=p.append("g").attr("class","y axis").call(q.y.fn);var r=6;q.x.label=o.append("text").attr("class","axis-label").text(b.x.label).attr("text-anchor","middle").attr("dominant-baseline","text-after-edge").attr("x",b.width/2+b.margin.left).attr("y",b.height+b.margin.bottom+b.margin.top-r),q.y.label=o.append("text").attr("class","axis-label").text(b.y.label).attr("text-anchor","middle").attr("dominant-baseline","text-before-edge").attr("x",r).attr("y",b.height/2).attr("transform",i(-90,r,b.height/2)),q.redraw=function(){o.select(".x.axis").call(q.x.fn),o.select(".y.axis").call(q.y.fn)};var s=d(),t=p.selectAll(".glyph").data(c).enter().append("svg:circle").classed("glyph",!0).attr("cx",function(a,b){return m.x(j(a,b))}).attr("cy",function(a,b){return m.y(k(a,b))}).attr("r",0);t.transition().duration(b.animDuration).attr("r",b.datapointSize),e(),n.on("zoom",f),t.on("mouseover",function(a,c){var d=d3.select(this);d.style("fill","red").style("fill-opacity",1),p.append("line").attr("stroke","red").attr("stroke-width",1).attr("x1",d.attr("cx")-b.datapointSize).attr("y1",d.attr("cy")).attr("x2",0).attr("y2",d.attr("cy")).classed("hoverline",!0),d.attr("cy")= 1.0.0"],c=this.merge(c,a.helpers),e=e||{};var f,g="",h="function",i=this.escapeExpression;return g+='

            \n Use the following controls to how the chart is displayed.\n The slide controls can be moved by the mouse or, if the \'handle\' is in focus, your keyboard\'s arrow keys.\n Move the focus between controls by using the tab or shift+tab keys on your keyboard.\n Use the \'Draw\' button to render (or re-render) the chart with the current settings.\n

            \n\n
            \n \n
            ',(f=c.datapointSize)?f=f.call(b,{hash:{},data:e}):(f=b.datapointSize,f=typeof f===h?f.apply(b):f),g+=i(f)+'
            \n
            \n

            \n Size of the graphic representation of each data point\n

            \n
            \n\n
            \n \n
            ',(f=c.width)?f=f.call(b,{hash:{},data:e}):(f=b.width,f=typeof f===h?f.apply(b):f),g+=i(f)+'
            \n
            \n

            \n (not including chart margins and axes)\n

            \n
            \n\n
            \n \n
            ',(f=c.height)?f=f.call(b,{hash:{},data:e}):(f=b.height,f=typeof f===h?f.apply(b):f),g+=i(f)+'
            \n
            \n

            \n (not including chart margins and axes)\n

            \n
            \n\n
            \n \n \n

            \n
            \n\n
            \n \n \n

            \n
            \n\n\n'}),this.scatterplot.datacontrol=Handlebars.template(function(a,b,c,d,e){function f(a,b){var d,e="";return e+='\n \n "}function g(){return'checked="true"'}this.compilerInfo=[4,">= 1.0.0"],c=this.merge(c,a.helpers),e=e||{};var h,i="",j="function",k=this.escapeExpression,l=this;return i+='

            \n Use the following controls to change the data used by the chart.\n Use the \'Draw\' button to render (or re-render) the chart with the current settings.\n

            \n\n\n
            \n \n \n
            \n
            \n \n \n
            \n\n\n
            \n \n \n

            \n These will be displayed (along with the x and y values) when you hover over\n a data point.\n

            \n
            \n\n\n\n\n\n\n'}),this.scatterplot.editor=Handlebars.template(function(a,b,c,d,e){this.compilerInfo=[4,">= 1.0.0"],c=this.merge(c,a.helpers),e=e||{};var f="";return f+='
            \n \n \n\n \n
            \n \n
            \n \n
            \n \n \n
            \n \n
            \n\n \n
            \n\n
            \n
            \n'});var ScatterplotConfigEditor=Backbone.View.extend(LoggableMixin).extend({className:"scatterplot-control-form",initialize:function(a){if(this.model||(this.model=new Visualization({type:"scatterplot"})),this.log(this+".initialize, attributes:",a),!a||!a.dataset)throw new Error("ScatterplotConfigEditor requires a dataset");this.dataset=a.dataset,this.log("dataset:",this.dataset),this.display=new ScatterplotDisplay({dataset:a.dataset,model:this.model})},render:function(){this.$el.empty().append(ScatterplotConfigEditor.templates.mainLayout({})),this.model.id&&(this.$el.find(".copy-btn").show(),this.$el.find(".save-btn").text("Update saved")),this.$el.find("[title]").tooltip(),this._render_dataControl(),this._render_chartControls(),this._render_chartDisplay();var a=this.model.get("config");return this.model.id&&_.isFinite(a.xColumn)&&_.isFinite(a.yColumn)&&this.renderChart(),this},_render_dataControl:function(a){a=a||this.$el;var b=this,c=this.dataset,d=c.metadata_column_names||[],e=this.model.get("config"),f=[],g=_.map(c.metadata_column_types,function(a,b){var c={index:b,type:a,name:d[b]||"column "+(b+1)};return("int"===c.type||"float"===c.type)&&f.push(c),c});f.length<2&&(f=g);var h=a.find(".tab-pane#data-control");h.html(ScatterplotConfigEditor.templates.dataControl({allColumns:g,numericColumns:f}));var i={xColumn:_.isFinite(e.xColumn)?e.xColumn:f[0].index,yColumn:_.isFinite(e.yColumn)?e.yColumn:f[1].index,idColumn:g[0].index};if(_.isFinite(e.idColumn))i.idColumn=e.idColumn;else if(g.length>2){var j=_.find(g,function(a,b){return b!==i.xColumn&&b!==i.yColumn});i.idColumn=j.index}return e=this.model.set("config",i,{silent:!0}).get("config"),h.find('[name="xColumn"]').val(e.xColumn).on("change",function(){b.model.set("config",{xColumn:Number($(this).val())})}),h.find('[name="yColumn"]').val(e.yColumn).on("change",function(){b.model.set("config",{yColumn:Number($(this).val())})}),h.find('select[name="idColumn"]').val(e.idColumn).on("change",function(){b.model.set("config",{idColumn:Number($(this).val())})}),void 0!==e.idColumn&&h.find("#include-id-checkbox").prop("checked",!0).trigger("change"),h.find("[title]").tooltip(),h},_render_chartControls:function(a){function b(){var a=$(this),b=a.slider("value");c.model.set("config",_.object([[a.parent().data("config-key"),b]])),a.siblings(".slider-output").text(b)}a=a||this.$el;var c=this,d=this.model.get("config"),e=a.find("#chart-control");e.html(ScatterplotConfigEditor.templates.chartControl(d));var f={datapointSize:{min:2,max:10,step:1},width:{min:200,max:800,step:20},height:{min:200,max:800,step:20}};e.find(".numeric-slider-input").each(function(){var a=$(this),c=a.attr("data-config-key"),e=_.extend(f[c],{value:d[c],change:b,slide:b});a.find(".slider").slider(e),a.children(".slider-output").text(d[c])});var g=this.dataset.metadata_column_names||[],h=d.xLabel||g[d.xColumn]||"X",i=d.yLabel||g[d.yColumn]||"Y";return e.find('input[name="X-axis-label"]').val(h).on("change",function(){c.model.set("config",{xLabel:$(this).val()})}),e.find('input[name="Y-axis-label"]').val(i).on("change",function(){c.model.set("config",{yLabel:$(this).val()})}),e.find("[title]").tooltip(),e},_render_chartDisplay:function(a){a=a||this.$el;var b=a.find(".tab-pane#chart-display");return this.display.setElement(b),this.display.render(),b.find("[title]").tooltip(),b},events:{"change #include-id-checkbox":"toggleThirdColumnSelector","click #data-control .render-button":"renderChart","click #chart-control .render-button":"renderChart","click .save-btn":"saveVisualization"},saveVisualization:function(){var a=this;this.model.save().fail(function(b,c,d){console.error(b,c,d),a.trigger("save:error",view),alert("Error loading data:\n"+b.responseText)}).then(function(){a.render()})},toggleThirdColumnSelector:function(){this.$el.find('select[name="idColumn"]').parent().toggle()},renderChart:function(){this.$el.find(".nav li.disabled").removeClass("disabled"),this.$el.find("ul.nav").find('a[href="#chart-display"]').tab("show"),this.display.fetchData()},toString:function(){return"ScatterplotConfigEditor("+(this.dataset?this.dataset.id:"")+")"}});ScatterplotConfigEditor.templates={mainLayout:scatterplot.editor,dataControl:scatterplot.datacontrol,chartControl:scatterplot.chartcontrol};var ScatterplotDisplay=Backbone.View.extend({initialize:function(a){this.data=null,this.dataset=a.dataset,this.calcNumPages()},calcNumPages:function(){var a=this.model.get("config");this.lineCount=this.dataset.metadata_data_lines,this.numPages=this.lineCount?Math.ceil(this.lineCount/a.pagination.perPage):void 0,this.lineCount&&void 0!==this.numPages||console.warn("no data total found")},fetchData:function(){this.showLoadingIndicator("getting data");var a=this,b=this.model.get("config"),c=jQuery.getJSON("/api/datasets/"+this.dataset.id,{data_type:"raw_data",provider:"dataset-column",limit:b.pagination.perPage,offset:b.pagination.currPage*b.pagination.perPage});return c.done(function(b){a.data=b.data,a.trigger("data:fetched",a),a.renderData()}),c.fail(function(b,c,d){console.error(b,c,d),a.trigger("data:error",a),alert("Error loading data:\n"+b.responseText)}),c},showLoadingIndicator:function(){this.$el.find(".scatterplot-data-info").html(['
            ','','loading...',"
            "].join(""))},template:function(){var a=['
            ','
            ',"
            ",'
            ','

            ','','',"
            ","
            ","",'
            '].join("");return a},render:function(){return this.$el.addClass("scatterplot-display").html(this.template()),this.data&&this.renderData(),this},renderData:function(){this.renderLeftControls(),this.renderRightControls(),this.renderPlot(this.data),this.getStats()},renderLeftControls:function(){return this.lineCount?this.$el.find(".controls .left").empty().append(this.renderPagination()):this.$el.find(".controls .left").empty().append(this.renderPrevNext()),this},renderRightControls:function(){var a=this;this.setLineInfo(this.data),this.$el.find(".stats-toggle-btn").off().click(function(){a.toggleStats()}),this.$el.find(".rerender-btn").off().click(function(){a.resetZoom(),a.renderPlot(this.data)})},renderPlot:function(){var a=this,b=this.$el.find("svg");this.toggleStats(!1),b.off().empty().show().on("zoom.scatterplot",function(b,c){a.model.set("config",c)}),scatterplot(b.get(0),this.model.get("config"),this.data)},setLineInfo:function(a,b){if(a){var c=this.model.get("config"),d=this.lineCount||"an unknown total",e=c.pagination.currPage*c.pagination.perPage,f=e+a.length;this.$el.find(".controls p.scatterplot-data-info").text([e+1,"to",f,"of",d].join(" "))}else this.$el.find(".controls p.scatterplot-data-info").html(b||"");return this},resetZoom:function(a,b){return a=void 0!==a?a:1,b=void 0!==b?b:[0,0],this.model.set("config",{scale:a,translate:b}),this},goToPage:function(a){var b=this.model.get("config").pagination;return 0>=a&&(a=0),this.numPages&&a>=this.numPages&&(a=this.numPages-1),a===b.currPage?this:(b.currPage=a,this.model.set("config",{pagination:b}),this.resetZoom(),this.fetchData(),this)},nextPage:function(){var a=this.model.get("config").pagination.currPage;return this.goToPage(a+1)},prevPage:function(){var a=this.model.get("config").pagination.currPage;return this.goToPage(a-1)},renderPrevNext:function(){var a=this.model.get("config");if(!this.data)return null;if(0===a.pagination.currPage&&this.data.lengthPrev').click(function(){b.prevPage()}),d=$('
          • Next
          • ').click(function(){b.nextPage()});return 0===a.pagination.currPage&&c.addClass("disabled"),this.numPages&&a.pagination.currPage===this.numPages-1&&d.addClass("disabled"),$("
              ").addClass("pagination data-prev-next").append([c,d])},renderPagination:function(){var a=this.model.get("config");if(!this.data)return null;if(!this.numPages)return null;if(0===a.pagination.currPage&&this.data.length").addClass("pagination data-pages");pageNumClick=function(){b.goToPage($(this).data("page"))};for(var d=0;d',d+1,""].join("")).attr("data-page",d).click(pageNumClick);d===a.pagination.currPage&&e.addClass("active"),c.append(e)}return c},getStats:function(){if(this.data){var a=this,b=this.model.get("config"),c=new Worker("/plugins/visualizations/scatterplot/static/worker-stats.js");c.postMessage({data:this.data,keys:[b.xColumn,b.yColumn]}),c.onerror=function(){c.terminate()},c.onmessage=function(b){a.renderStats(b.data)}}},renderStats:function(a){var b=this.model.get("config"),c=this.$el.find(".stats-display"),d=b.x.label,e=b.y.label,f=$("
            ",c,"",d,"
            ",b,"",a[0],"",a[1],"
            ").addClass("table").append([""].join("")).append(_.map(a,function(a,b){return $([""].join(""))}));c.empty().append(f)},toggleStats:function(a){var b=this.$el.find(".stats-display");a=void 0===a?b.is(":hidden"):a,a?(this.$el.find("svg").hide(),b.show(),this.$el.find(".controls .stats-toggle-btn").text("Plot")):(b.hide(),this.$el.find("svg").show(),this.$el.find(".controls .stats-toggle-btn").text("Stats"))},toString:function(){return"ScatterplotView()"}}),ScatterplotModel=Visualization.extend({defaults:{type:"scatterplot",config:{pagination:{currPage:0,perPage:3e3},width:400,height:400,margin:{top:16,right:16,bottom:40,left:54},x:{ticks:10,label:"X"},y:{ticks:10,label:"Y"},datapointSize:4,animDuration:500,scale:1,translate:[0,0]}}}); \ No newline at end of file diff --git a/config/plugins/visualizations/scatterplot/static/scatterplot.css b/config/plugins/visualizations/scatterplot/static/scatterplot.css index 7063be7e939..f3105d7568c 100644 --- a/config/plugins/visualizations/scatterplot/static/scatterplot.css +++ b/config/plugins/visualizations/scatterplot/static/scatterplot.css @@ -39,6 +39,22 @@ font-size: smaller; } +.scatterplot-editor .file-controls { + float: right; + /*margin-right: 16px;*/ +} + +.file-controls .btn { + height: 24px; + padding: 0px 10px 0px 10px; + line-height: 24px; +} + +.file-controls .copy-btn { + display: none; +} + + /* ============================================ config controls */ .scatterplot-config-control { max-width: 768px; @@ -84,8 +100,6 @@ /* ============================================ plot display */ /* -------------------------------------------- load indicators */ .scatterplot-display .loading-indicator { - position: absolute; - margin: 10px 0px 0px 10px; color: grey; } diff --git a/config/plugins/visualizations/scatterplot/templates/scatterplot.mako b/config/plugins/visualizations/scatterplot/templates/scatterplot.mako index a6a1a182106..06d9a65b4e9 100644 --- a/config/plugins/visualizations/scatterplot/templates/scatterplot.mako +++ b/config/plugins/visualizations/scatterplot/templates/scatterplot.mako @@ -1,18 +1,8 @@ <% - hda_dict = trans.security.encode_dict_ids( hda.to_dict() ) - - config = query_args - title = "Scatterplot of '" + hda.name + "'" - info = hda.info - - visualization = context.get( 'visualization' ) - if visualization is not None: - config = visualization.latest_revision.config - config.update( query_args ) - title = visualization.title - info = config.get( 'description', info ) - - config[ 'type' ] = 'scatterplot' + default_title = "Scatterplot of '" + hda.name + "'" + info = hda.name + if hda.info: + info += ' : ' + hda.info # optionally bootstrap data from dprov ##data = list( hda.datatype.dataset_column_dataprovider( hda, limit=10000 ) ) @@ -23,7 +13,7 @@ -${hda.name} | ${visualization_name} +${title or default_title} | ${visualization_display_name} ## ---------------------------------------------------------------------------- @@ -52,23 +42,29 @@ %if not embedded: ## dataset info: only show if on own page
            -

            ${title}

            +

            ${title or default_title}

            ${info}

            diff --git a/lib/galaxy/visualization/registry.py b/lib/galaxy/visualization/registry.py index 5e0832c8e82..99d5c14044e 100644 --- a/lib/galaxy/visualization/registry.py +++ b/lib/galaxy/visualization/registry.py @@ -223,7 +223,7 @@ class VisualizationsRegistry( pluginframework.PageServingPluginManager ): def is_object_applicable( self, trans, target_object, data_source_tests ): """ Run a visualization's data_source tests to find out if - it be applied to the target_object. + it can be applied to the target_object. """ #log.debug( 'is_object_applicable( self, trans, %s, %s )', target_object, data_source_tests ) for test in data_source_tests: @@ -306,9 +306,9 @@ class VisualizationsRegistry( pluginframework.PageServingPluginManager ): Both `params` and `param_modifiers` default to an empty dictionary. """ - visualization = self.plugins.get( visualization_name ) - expected_params = visualization.config.get( 'params', {} ) - param_modifiers = visualization.config.get( 'param_modifiers', {} ) + plugin = self.plugins.get( visualization_name ) + expected_params = plugin.config.get( 'params', {} ) + param_modifiers = plugin.config.get( 'param_modifiers', {} ) return ( expected_params, param_modifiers ) def query_dict_to_resources( self, trans, controller, visualization_name, query_dict ): @@ -322,6 +322,16 @@ class VisualizationsRegistry( pluginframework.PageServingPluginManager ): trans, controller, param_confs, query_dict, param_modifiers ) return resources + def query_dict_to_config( self, trans, controller, visualization_name, query_dict ): + """ + Given a query string dict (i.e. kwargs) from a controller action, parse + and return any key/value pairs found in the plugin's `params` section. + """ + plugin = self.plugins.get( visualization_name ) + param_confs = plugin.config.get( 'params', {} ) + config = self.resource_parser.parse_config( trans, controller, param_confs, query_dict ) + return config + # ------------------------------------------------------------------- parsing the config file class ParsingException( ValueError ): @@ -559,37 +569,38 @@ class DataSourceParser( object ): for test_elem in xml_tree_list: test_type = test_elem.get( 'type', 'eq' ) - test_result = test_elem.text + test_result = test_elem.text.strip() if test_elem.text else None if not test_type or not test_result: log.warn( 'Skipping test. Needs both type attribute and text node to be parsed: ' + '%s, %s' %( test_type, test_elem.text ) ) continue + test_result = test_result.strip() # test_attr can be a dot separated chain of object attributes (e.g. dataset.datatype) - convert to list #TODO: too dangerous - constrain these to some allowed list #TODO: does this err if no test_attr - it should... test_attr = test_elem.get( 'test_attr' ) test_attr = test_attr.split( self.ATTRIBUTE_SPLIT_CHAR ) if isinstance( test_attr, str ) else [] + #log.debug( 'test_type: %s, test_attr: %s, test_result: %s', test_type, test_attr, test_result ) + # build a lambda function that gets the desired attribute to test getter = self._build_getattr_lambda( test_attr ) - # result type should tell the registry how to convert the result before the test test_result_type = test_elem.get( 'result_type', 'string' ) # test functions should be sent an object to test, and the parsed result expected from the test - - # is test_attr attribute an instance of result if test_type == 'isinstance': + # is test_attr attribute an instance of result #TODO: wish we could take this further but it would mean passing in the datatypes_registry test_fn = lambda o, result: isinstance( getter( o ), result ) - # does the object itself have a datatype attr and does that datatype have the given dataprovider elif test_type == 'has_dataprovider': + # does the object itself have a datatype attr and does that datatype have the given dataprovider test_fn = lambda o, result: ( hasattr( getter( o ), 'has_dataprovider' ) and getter( o ).has_dataprovider( result ) ) - # default to simple (string) equilavance (coercing the test_attr to a string) else: + # default to simple (string) equilavance (coercing the test_attr to a string) test_fn = lambda o, result: str( getter( o ) ) == result tests.append({ @@ -737,6 +748,16 @@ class ResourceParser( object ): The keys used to store the new values can optionally be re-mapped to new keys (e.g. dataset_id="NNN" -> hda=). """ + primitive_parsers = { + 'str' : lambda param: galaxy.util.sanitize_html.sanitize_html( param, 'utf-8' ), + 'bool' : lambda param: galaxy.util.string_as_bool( param ), + 'int' : lambda param: int( param ), + 'float' : lambda param: float( param ), + #'date' : lambda param: , + 'json' : ( lambda param: galaxy.util.json.from_json_string( + galaxy.util.sanitize_html.sanitize_html( param ) ) ), + } + #TODO: kinda torn as to whether this belongs here or in controllers.visualization # taking the (questionable) design path of passing a controller in # (which is the responsible party for getting model, etc. resources ) @@ -749,8 +770,6 @@ class ResourceParser( object ): If param is required and not present, raises a `KeyError`. """ #log.debug( 'parse_parameter_dictionary, query_params:\n%s', query_params ) - # first parse any params from any visualizations that were passed - query_params = self.get_params_from_visualization_param( trans, controller, param_config_dict, query_params ) # parse the modifiers first since they modify the params coming next #TODO: this is all really for hda_ldda - which we could replace with model polymorphism @@ -782,12 +801,43 @@ class ResourceParser( object ): if resource == None: if param_config[ 'required' ]: raise KeyError( 'required param %s not found in URL' %( param_name ) ) - resource = self.parse_parameter_default( trans, param_config ) + resource = self.parse_parameter_default( trans, controller, param_config ) resources[ var_name_in_template ] = resource return resources + def parse_config( self, trans, controller, param_config_dict, query_params ): + """ + Return `query_params` dict parsing only JSON serializable params. + Complex params such as models, etc. are left as the original query value. + Keys in `query_params` not found in the `param_config_dict` will not be + returned. + """ + #log.debug( 'parse_config, query_params:\n%s', query_params ) + config = {} + for param_name, param_config in param_config_dict.items(): + config_val = query_params.get( param_name, None ) + if config_val is not None and param_config[ 'type' ] in self.primitive_parsers: + try: + config_val = self.parse_parameter( trans, controller, param_config, config_val ) + + except Exception, exception: + log.warn( 'Exception parsing visualization param from query: ' + + '%s, %s, (%s) %s' %( param_name, config_val, str( type( exception ) ), str( exception ) )) + config_val = None + + # here - we've either had no value in the query_params or there was a failure to parse + # so: if there's a default and it's not None, add it to the config + if config_val is None: + if param_config.get( 'default', None ) is None: + continue + config_val = self.parse_parameter_default( trans, controller, param_config ) + + config[ param_name ] = config_val + + return config + #TODO: I would LOVE to rip modifiers out completely def parse_parameter_modifiers( self, trans, controller, param_modifiers, query_params ): """ @@ -811,11 +861,11 @@ class ResourceParser( object ): target_modifiers[ modifier_name ] = modifier else: #TODO: required attr? - target_modifiers[ modifier_name ] = self.parse_parameter_default( trans, modifier_config ) + target_modifiers[ modifier_name ] = self.parse_parameter_default( trans, controller, modifier_config ) return parsed_modifiers - def parse_parameter_default( self, trans, param_config ): + def parse_parameter_default( self, trans, controller, param_config ): """ Parse any default values for the given param, defaulting the default to `None`. @@ -828,46 +878,8 @@ class ResourceParser( object ): # otherwise, parse (currently param_config['default'] is a string just like query param and needs to be parsed) # this saves us the trouble of parsing the default when the config file is read # (and adding this code to the xml parser) - return self.parse_parameter( trans, param_config, default ) + return self.parse_parameter( trans, controller, param_config, default ) - def get_params_from_visualization_param( self, trans, controller, param_config_dict, query_params ): - #log.debug( 'parse_visualization_params: %s', param_config_dict ) - #log.debug( ' : %s', query_params ) - - # first, find the visualization in the parameters if any - visualization = None - #precondition: assume one visualization - for param_name, param_config in param_config_dict.items(): - if param_config.get( 'type' ) == 'visualization': - query_val = query_params.get( param_name ) - if query_val is None: - continue - - #log.debug( 'found visualization param: %s, %s', param_name, query_val ) - visualization = self.parse_parameter( trans, controller, param_config, query_val ) - if visualization: - break - - # if no vis is found, can't get any new params from it: return the original query_params - if not visualization: - #log.debug( 'visualization not found' ) - return query_params - #log.debug( 'found visualization: %s', visualization ) - - # next, attempt to copy any params from the visualizations config - visualization_config = visualization.latest_revision.config - #log.debug( '\t config: %s', visualization_config ) - params_from_visualization = {} - for param_name, param_config in param_config_dict.items(): - if param_name in visualization_config: - params_from_visualization[ param_name ] = visualization_config[ param_name ] - #log.debug( 'params_from_visualization: %s', params_from_visualization ) - - # layer the query_params over the params from the visualization, returning the combined - params_from_visualization.update( query_params ) - return params_from_visualization - -#TODO: make parse_visualization separate def parse_parameter( self, trans, controller, expected_param_data, query_param, recurse=True, param_modifiers=None ): """ @@ -892,24 +904,11 @@ class ResourceParser( object ): parsed_param.append( self._parse_param( trans, expected_param_data, query_param, recurse=False ) ) return parsed_param - primitive_parsers = { - 'str' : lambda param: galaxy.util.sanitize_html.sanitize_html( param, 'utf-8' ), - 'bool' : lambda param: galaxy.util.string_as_bool( param ), - 'int' : lambda param: int( param ), - 'float' : lambda param: float( param ), - #'date' : lambda param: , - 'json' : ( lambda param: galaxy.util.json.from_json_string( - galaxy.util.sanitize_html.sanitize_html( param ) ) ), - } - parser = primitive_parsers.get( param_type, None ) - if parser: + if param_type in self.primitive_parsers: #TODO: what about param modifiers on primitives? - parsed_param = parser( query_param ) + parsed_param = self.primitive_parsers[ param_type ]( query_param ) - #TODO: constrain_to - # this gets complicated - for strings - relatively simple but still requires splitting and using in - # for more complicated cases (ints, json) this gets weird quick - #TODO:?? remove? + #TODO: constrain_to: this gets complicated - remove? # db models #TODO: subclass here? diff --git a/lib/galaxy/webapps/galaxy/controllers/visualization.py b/lib/galaxy/webapps/galaxy/controllers/visualization.py index 990e48aed91..d111da9a458 100644 --- a/lib/galaxy/webapps/galaxy/controllers/visualization.py +++ b/lib/galaxy/webapps/galaxy/controllers/visualization.py @@ -1,9 +1,10 @@ from __future__ import absolute_import import os +import copy from sqlalchemy import desc, or_, and_ -from paste.httpexceptions import HTTPNotFound +from paste.httpexceptions import HTTPNotFound, HTTPBadRequest from galaxy import model, web from galaxy.model.item_attrs import UsesAnnotations, UsesItemRatings @@ -13,6 +14,9 @@ from galaxy import util from galaxy.datatypes.interval import Bed from galaxy.util.json import from_json_string from galaxy.util.sanitize_html import sanitize_html +from galaxy.util import bunch +from galaxy import util +from galaxy.visualization import registry from galaxy.visualization.genomes import decode_dbkey from galaxy.visualization.data_providers.phyloviz import PhylovizDataProvider from galaxy.visualization.genomes import GenomeRegion @@ -22,6 +26,27 @@ from .library import LibraryListGrid import logging log = logging.getLogger( __name__ ) +# -- Misc. +class OpenObject( dict ): + #TODO: move to util.data_structures + """ + A dict that allows assignment and attribute retrieval using the dot + operator. + + If an attribute isn't contained in the dict `None` is returned (no + KeyError). + JSON-serializable. + """ + def __getitem__( self, key ): + if key not in self: + return None + return super( OpenObject, self ).__getitem__( key ) + + def __getattr__( self, key ): + return self.__getitem__( key ) + + + # # -- Grids -- # @@ -168,9 +193,14 @@ class VisualizationListGrid( grids.Grid ): """ Returns dictionary used to create item link. """ - controller = "visualization" - action = item.type - return dict( controller=controller, action=action, id=item.id ) + url_kwargs = dict( controller='visualization', id=item.id ) + #TODO: hack to build link to saved visualization - need trans in this function instead in order to do + #link_data = trans.app.visualizations_registry.get_visualizations( trans, item ) + if item.type in registry.VisualizationsRegistry.BUILT_IN_VISUALIZATIONS: + url_kwargs[ 'action' ] = item.type + else: + url_kwargs[ 'action' ] = 'saved' + return url_kwargs # Grid definition title = "Saved Visualizations" @@ -179,6 +209,7 @@ class VisualizationListGrid( grids.Grid ): default_filter = dict( title="All", deleted="False", tags="All", sharing="All" ) columns = [ grids.TextColumn( "Title", key="title", attach_popup=True, link=get_url_args ), + #TODO: should use display name when available grids.TextColumn( "Type", key="type" ), grids.TextColumn( "Dbkey", key="dbkey" ), grids.IndividualTagsColumn( "Tags", key="tags", model_tag_association_class=model.VisualizationTagAssociation, filterable="advanced", grid_name="VisualizationListGrid" ), @@ -623,7 +654,6 @@ class VisualizationController( BaseUIController, SharableMixin, UsesAnnotations, Save a visualization; if visualization does not have an ID, a new visualization is created. Returns JSON of visualization. """ - # Get visualization attributes from kwargs or from config. vis_config = from_json_string( vis_json ) vis_type = type or vis_config[ 'type' ] @@ -686,10 +716,45 @@ class VisualizationController( BaseUIController, SharableMixin, UsesAnnotations, # # Visualizations. # + @web.expose + @web.require_login( "use Galaxy visualizations", use_panels=True ) + def saved( self, trans, id=None, revision=None, type=None, config=None, title=None, **kwargs ): + """ + """ + DEFAULT_VISUALIZATION_NAME = 'Unnamed Visualization' + + # post to saved in order to save a visualization + #TODO: re-route this one to clear up signature + if trans.request.method == 'POST': + if type is None or config is None: + return HTTPBadRequest( 'A visualization type and config are required to save a visualization' ) + if isinstance( config, basestring ): + config = from_json_string( config ) + title = title or DEFAULT_VISUALIZATION_NAME + #TODO: allow saving to (updating) a specific revision - should be part of UsesVisualization + #TODO: would be easier if this returned the visualization directly + returned = self.save_visualization( trans, config, type, id, title ) + + # redirect to GET to prevent annoying 'Do you want to post again?' dialog on page reload + render_url = web.url_for( controller='visualization', action='saved', id=returned.get( 'vis_id' ) ) + return trans.response.send_redirect( render_url ) + + if id is None: + return HTTPBadRequest( 'A valid visualization id is required to load a visualization' ) + + # render the saved visualization by passing to render, sending latest revision config + #TODO: allow loading a specific revision - should be part of UsesVisualization + visualization = self.get_visualization( trans, id, check_ownership=True, check_accessible=False ) + config = copy.copy( visualization.latest_revision.config ) + + # re-add title to kwargs for passing to render + if title: + kwargs[ 'title' ] = title + return self.render( trans, visualization.type, visualization, config=config, **kwargs ) @web.expose @web.require_login( "use Galaxy visualizations", use_panels=True ) - def render( self, trans, visualization_name, embedded=None, **kwargs ): + def render( self, trans, visualization_name, visualization=None, config=None, embedded=None, **kwargs ): """ Render the appropriate visualization template, parsing the `kwargs` into appropriate variables and resources (such as ORM models) @@ -697,6 +762,8 @@ class VisualizationController( BaseUIController, SharableMixin, UsesAnnotations, URL: /visualization/show/{visualization_name} """ + config = config or {} + # validate name vs. registry registry = trans.app.visualizations_registry if not registry: @@ -708,20 +775,32 @@ class VisualizationController( BaseUIController, SharableMixin, UsesAnnotations, returned = None try: - # convert query string to resources for template based on registry config - #NOTE: passing in controller to keep resource lookup within the controller's responsibilities - # (and not the ResourceParser) - resources = registry.query_dict_to_resources( trans, self, visualization_name, kwargs ) + # get the config for passing to the template from the kwargs dict, parsed using the plugin's params setting + config_from_kwargs = registry.query_dict_to_config( trans, self, visualization_name, kwargs ) + config.update( config_from_kwargs ) + config = OpenObject( **config ) + # further parse config to resources (models, etc.) used in template based on registry config + resources = registry.query_dict_to_resources( trans, self, visualization_name, config ) + + # if a saved visualization, pass in the encoded visualization id or None if a new render + encoded_visualization_id = None + if visualization: + encoded_visualization_id = trans.security.encode_id( visualization.id ) + + visualization_display_name = plugin.config[ 'name' ] + title = visualization.latest_revision.title if visualization else kwargs.get( 'title', None ) # look up template and render template_path = plugin.config[ 'template' ] returned = registry.fill_template( trans, plugin, template_path, - visualization_name=visualization_name, query_args=kwargs, - embedded=embedded, shared_vars={}, **resources ) - #NOTE: passing *unparsed* kwargs as query_args - #NOTE: shared_vars is a dictionary for shared data in the template + visualization_name=visualization_name, visualization_display_name=visualization_display_name, + title=title, saved_visualization=visualization, visualization_id=encoded_visualization_id, + embedded=embedded, query=kwargs, vars={}, config=config, **resources ) + #NOTE: passing *unparsed* kwargs as query + #NOTE: vars is a dictionary for shared data in the template # this feels hacky to me but it's what mako recommends: # http://docs.makotemplates.org/en/latest/runtime.html + #TODO: should vars contain all the passed in arguments? is that even necessary? #TODO: embedded except Exception, exception:
            ",d,"",e,"
            ",b,"",a[0],"",a[1],"