UI: add peek-based column selection control; Scatterplot: use peek-based control for selection of x, y, id axes

This commit is contained in:
Carl Eberhard
2014-02-17 12:11:04 -05:00
parent 7951cec806
commit 9e32945972
8 changed files with 451 additions and 105 deletions
@@ -1,55 +1,24 @@
<p class="help-text">
Use the following controls to change the data used by the chart.
Use the following control to change which columns are used by the chart. Click any cell
from the last three rows of the table to select the column for the appropriate data.
Use the 'Draw' button to render (or re-render) the chart with the current settings.
</p>
{{! column selector containers }}
<div class="column-select">
<label>Data column for X: </label>
<select name="xColumn">
{{#each numericColumns}}
<option value="{{index}}">{{name}}</option>
{{/each}}
</select>
</div>
<div class="column-select">
<label>Data column for Y: </label>
<select name="yColumn">
{{#each numericColumns}}
<option value="{{index}}">{{name}}</option>
{{/each}}
</select>
<ul class="help-text" style="margin-left: 8px">
<li><b>X Column</b>: which column values will be used for the x axis of the chart.</li>
<li><b>Y Column</b>: which column values will be used for the y axis of the chart.</li>
<li><b>ID Column</b>: an additional column value displayed when the user hovers over a data point.
It may be useful to select unique or categorical identifiers here (such as gene ids).
</li>
</ul>
<div class="column-selection">
<pre class="peek">{{{ peek }}}</pre>
</div>
{{! optional id column }}
<div id="include-id">
<label for="include-id-checkbox">Include a third column as data point IDs?</label>
<input type="checkbox" name="include-id" id="include-id-checkbox" />
<p class="help-text-small">
These will be displayed (along with the x and y values) when you hover over
a data point.
</p>
</div>
<div class="column-select" style="display: none">
<label for="ID-select">Data column for IDs: </label>
<select name="idColumn">
{{#each allColumns}}
<option value="{{index}}">{{name}}</option>
{{/each}}
</select>
</div>
{{! if we're using generic column selection names ('column 1') - allow the user to use the first line }}
<div id="first-line-header" style="display: none;">
<p>Possible headers: {{ possibleHeaders }}
</p>
<label for="first-line-header-checkbox">Use the above as column headers?</label>
<input type="checkbox" name="include-id" id="first-line-header-checkbox"
{{#if usePossibleHeaders }}checked="true"{{/if}}/>
<p class="help-text-small">
It looks like Galaxy couldn't get proper column headers for this data.
Would you like to use the column headers above as column names to select columns?
</p>
</div>
<p class="help-text help-text-small">
<b>Note</b>: If it can be determined from the dataset's filetype that column is not numeric, that
column choice may be disabled for either the x or y axis.
</p>
<button class="render-button btn btn-primary active">Draw</button>
@@ -78,73 +78,62 @@ var ScatterplotConfigEditor = Backbone.View.extend( LoggableMixin ).extend({
return this;
},
/** get an object with arrays keyed with possible column types (numeric, text, all)
* and if metadata_column_types is set on the dataset, add the indeces of each
* column into the appropriate array.
* Used to disable certain columns from being selected for x, y axes.
*/
_getColumnIndecesByType : function(){
//TODO: not sure these contraints are necc. now
var types = {
numeric : [],
text : [],
all : []
};
_.each( this.dataset.metadata_column_types || [], function( type, i ){
if( type === 'int' || type === 'float' ){
types.numeric.push( i );
} else if( type === 'str' || type === 'list' ){
types.text.push( i );
}
types.all.push( i );
});
if( types.numeric.length < 2 ){
types.numeric = [];
}
//console.log( 'types:', JSON.stringify( types ) );
return types;
},
/** 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 );
//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 );
//column_names = dataset.metadata_column_names || [],
config = this.model.get( 'config' ),
columnTypes = this._getColumnIndecesByType();
// render the html
var $dataControl = $where.find( '.tab-pane#data-control' );
$dataControl.html( ScatterplotConfigEditor.templates.dataControl({
allColumns : allColumns,
numericColumns : numericColumns
peek : this.dataset.peek
}));
//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( '.peek' ).peekControl({
controls : [
{ label: 'X Column', id: 'xColumn', selected: config.xColumn, disabled: columnTypes.text },
{ label: 'Y Column', id: 'yColumn', selected: config.yColumn, disabled: columnTypes.text },
{ label: 'ID Column', id: 'idColumn', selected: config.idColumn }
]
//renameColumns : true
$dataControl.find( '[name="xColumn"]' ).val( config.xColumn ).on( 'change', function(){
editor.model.set( 'config', { xColumn: Number( $( this ).val() ) });
}).on( 'peek-control.change', function( ev, data ){
//console.info( 'new selection:', data );
editor.model.set( 'config', data );
}).on( 'peek-control.rename', function( ev, data ){
//console.info( 'new column names', data );
});
$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( '[title]' ).tooltip();
return $dataControl;
File diff suppressed because one or more lines are too long
@@ -87,7 +87,7 @@ $(function(){
model : model,
dataset : hdaJson
}).render();
//window.editor = editor;
window.editor = editor;
$( '.chart-header h2' ).click( function(){
var returned = prompt( 'Enter a new title:' );
+307
View File
@@ -1299,3 +1299,310 @@ function dropDownSelect( options, selected ){
});
}());
//==============================================================================
/** Column selection using the peek display as the control.
* Adds rows to the bottom of the peek with clickable areas in each cell
* to allow the user to select columns.
* Column selection can be limited to a single column or multiple.
* (Optionally) adds a left hand column of column selection prompts.
* (Optionally) allows the column headers to be clicked/renamed
* and set to some initial value.
* (Optionally) hides comment rows.
* (Optionally) allows pre-selecting and disabling certain columns for
* each row control.
*
* Construct by selecting a peek table to be used with jQuery and
* calling 'peekControl' with options.
* Options must include a 'controls' array and can include other options
* listed below.
* @example:
* $( 'pre.peek' ).peekControl({
* columnNames : ["Chromosome", "Start", "Base", "", "", "Qual" ],
* controls : [
* { label: 'X Column', id: 'xColumn' },
* { label: 'Y Column', id: 'yColumn', selected: 2 },
* { label: 'ID Column', id: 'idColumn', selected: 4, disabled: [ 1, 5 ] },
* { label: 'Heatmap', id: 'heatmap', selected: [ 2, 4 ], disabled: [ 0, 1 ], multiselect: true,
* selectedText: 'Included', unselectedText: 'Excluded' }
* ],
* renameColumns : true,
* hideCommentRows : true,
* includePrompts : true,
* topLeftContent : 'Data sample:'
* }).on( 'peek-control.change', function( ev, selection ){
* console.info( 'new selection:', selection );
* //{ yColumn: 2 }
* }).on( 'peek-control.rename', function( ev, names ){
* console.info( 'column names', names );
* //[ 'Bler', 'Start', 'Base', '', '', 'Qual' ]
* });
*
* An event is fired when column selection is changed and the event
* is passed an object in the form: { the row id : the new selection value }.
* An event is also fired when the table headers are re-named and
* is passed the new array of column names.
*/
(function(){
/** option defaults */
var defaults = {
/** does this control allow renaming headers? */
renameColumns : false,
/** does this control allow renaming headers? */
columnNames : [],
/** the comment character used by the peek's datatype */
commentChar : '#',
/** should comment rows be shown or hidden in the peek */
hideCommentRows : false,
/** should a column of row control prompts be used */
includePrompts : true,
/** what is the content of the top left cell (often a title) */
topLeftContent : 'Columns:'
},
/** the string of the event fired when a control row changes */
CHANGE_EVENT = 'peek-control.change',
/** the string of the event fired when a column is renamed */
RENAME_EVENT = 'peek-control.rename',
/** class added to the pre.peek element (to allow css on just the control) */
PEEKCONTROL_CLASS = 'peek-control',
/** class added to the control rows */
ROW_CLASS = 'control',
/** class added to the left-hand cells that serve as row prompts */
PROMPT_CLASS = 'control-prompt',
/** class added to selected _cells_/tds */
SELECTED_CLASS = 'selected',
/** class added to disabled/un-clickable cells/tds */
DISABLED_CLASS = 'disabled',
/** class added to the clickable surface within a cell to select it */
BUTTON_CLASS = 'button',
/** class added to peek table header (th) cells to indicate they can be clicked and are renamable */
RENAMABLE_HEADER_CLASS = 'renamable-header',
/** the data key used for each cell to store the column index ('data-...') */
COLUMN_INDEX_DATA_KEY = 'column-index',
/** renamable header data key used to store the column name (w/o the number and dot: '1.Bler') */
COLUMN_NAME_DATA_KEY = 'column-name';
//TODO: not happy with pure functional here - rows should polymorph (multi, single, etc.)
//TODO: needs clean up, move handlers to outer scope
// ........................................................................
/** validate the control data sent in for each row */
function validateControl( control ){
if( control.disabled && jQuery.type( control.disabled ) !== 'array' ){
throw new Error( '"disabled" must be defined as an array of indeces: ' + JSON.stringify( control ) );
}
if( control.multiselect && control.selected && jQuery.type( control.selected ) !== 'array' ){
throw new Error( 'Mulitselect rows need an array for "selected": ' + JSON.stringify( control ) );
}
if( !control.label || !control.id ){
throw new Error( 'Peek controls need a label and id for each control row: ' + JSON.stringify( control ) );
}
if( control.disabled && control.disabled.indexOf( control.selected ) !== -1 ){
throw new Error( 'Selected column is in the list of disabled columns: ' + JSON.stringify( control ) );
}
return control;
}
/** build the inner control surface (i.e. button-like) */
function buildButton( control, columnIndex ){
return $( '<div/>' ).addClass( BUTTON_CLASS ).text( control.label );
}
/** build the basic (shared) cell structure */
function buildControlCell( control, columnIndex ){
var $td = $( '<td/>' )
.html( buildButton( control, columnIndex ) )
.attr( 'data-' + COLUMN_INDEX_DATA_KEY, columnIndex );
// disable if index in disabled array
if( control.disabled && control.disabled.indexOf( columnIndex ) !== -1 ){
$td.addClass( DISABLED_CLASS );
}
return $td;
}
/** set the text of the control based on selected/un */
function setSelectedText( $cell, control, columnIndex ){
var $button = $cell.children( '.' + BUTTON_CLASS );
if( $cell.hasClass( SELECTED_CLASS ) ){
$button.html( ( control.selectedText !== undefined )?( control.selectedText ):( control.label ) );
} else {
$button.html( ( control.unselectedText !== undefined )?( control.unselectedText ):( control.label ) );
}
}
/** build a cell for a row that only allows one selection */
function buildSingleSelectCell( control, columnIndex ){
// only one selection - selected is single index
var $cell = buildControlCell( control, columnIndex );
if( control.selected === columnIndex ){
$cell.addClass( SELECTED_CLASS );
}
setSelectedText( $cell, control, columnIndex );
// only add the handler to non-disabled controls
if( !$cell.hasClass( DISABLED_CLASS ) ){
$cell.click( function selectClick( ev ){
var $cell = $( this );
// don't re-select or fire event if already selected
if( !$cell.hasClass( SELECTED_CLASS ) ){
// only one can be selected - remove selected on all others, add it here
var $otherSelected = $cell.parent().children( '.' + SELECTED_CLASS ).removeClass( SELECTED_CLASS );
$otherSelected.each( function(){
setSelectedText( $( this ), control, columnIndex );
});
$cell.addClass( SELECTED_CLASS );
setSelectedText( $cell, control, columnIndex );
// fire the event from the table itself, passing the id and index of selected
var eventData = {},
key = $cell.parent().attr( 'id' ),
val = $cell.data( COLUMN_INDEX_DATA_KEY );
eventData[ key ] = val;
$cell.parents( '.peek' ).trigger( CHANGE_EVENT, eventData );
}
});
}
return $cell;
}
/** build a cell for a row that allows multiple selections */
function buildMultiSelectCell( control, columnIndex ){
var $cell = buildControlCell( control, columnIndex );
// multiple selection - selected is an array
if( control.selected && control.selected.indexOf( columnIndex ) !== -1 ){
$cell.addClass( SELECTED_CLASS );
}
setSelectedText( $cell, control, columnIndex );
// only add the handler to non-disabled controls
if( !$cell.hasClass( DISABLED_CLASS ) ){
$cell.click( function multiselectClick( ev ){
var $cell = $( this );
// can be more than one selected - toggle selected on this cell
$cell.toggleClass( SELECTED_CLASS );
setSelectedText( $cell, control, columnIndex );
var selectedColumnIndeces = $cell.parent().find( '.' + SELECTED_CLASS ).map( function( i, e ){
return $( e ).data( COLUMN_INDEX_DATA_KEY );
});
// fire the event from the table itself, passing the id and index of selected
var eventData = {},
key = $cell.parent().attr( 'id' ),
val = jQuery.makeArray( selectedColumnIndeces );
eventData[ key ] = val;
$cell.parents( '.peek' ).trigger( CHANGE_EVENT, eventData );
});
}
return $cell;
}
/** iterate over columns in peek and create a control for each */
function buildControlCells( count, control ){
var $cells = [];
// build a control for each column - using a build fn based on control
for( var columnIndex=0; columnIndex<count; columnIndex+=1 ){
$cells.push( control.multiselect? buildMultiSelectCell( control, columnIndex )
: buildSingleSelectCell( control, columnIndex ) );
}
return $cells;
}
/** build a row of controls for the peek */
function buildControlRow( cellCount, control, includePrompts ){
var $controlRow = $( '<tr/>' ).attr( 'id', control.id ).addClass( ROW_CLASS );
if( includePrompts ){
var $promptCell = $( '<td/>' ).addClass( PROMPT_CLASS ).text( control.label + ':' );
$controlRow.append( $promptCell );
}
$controlRow.append( buildControlCells( cellCount, control ) );
return $controlRow;
}
// ........................................................................
/** add to the peek, using options for configuration, return the peek */
function peekControl( options ){
options = jQuery.extend( true, {}, defaults, options );
var $peek = $( this ).addClass( PEEKCONTROL_CLASS ),
$peektable = $peek.find( 'table' ),
// get the size of the tables - width and height, number of comment rows
columnCount = $peektable.find( 'th' ).size(),
rowCount = $peektable.find( 'tr' ).size(),
// get the rows containing text starting with the comment char (also make them grey)
$commentRows = $peektable.find( 'td[colspan]' ).map( function( e, i ){
var $this = $( this );
if( $this.text() && $this.text().match( new RegExp( '^' + options.commentChar ) ) ){
return $( this ).css( 'color', 'grey' ).parent().get(0);
}
return null;
});
// should comment rows in the peek be hidden?
if( options.hideCommentRows ){
$commentRows.hide();
rowCount -= $commentRows.size();
}
//console.debug( 'rowCount:', rowCount, 'columnCount:', columnCount, '$commentRows:', $commentRows );
// should a first column of control prompts be added?
if( options.includePrompts ){
var $topLeft = $( '<th/>' ).addClass( 'top-left' ).text( options.topLeftContent )
.attr( 'rowspan', rowCount );
$peektable.find( 'tr' ).first().prepend( $topLeft );
}
// save either the options column name or the parsed text of each column header in html5 data attr and text
var $headers = $peektable.find( 'th:not(.top-left)' ).each( function( i, e ){
var $this = $( this ),
// can be '1.name' or '1'
text = $this.text().replace( /^\d+\.*/, '' ),
name = options.columnNames[ i ] || text;
$this.attr( 'data-' + COLUMN_NAME_DATA_KEY, name )
.text( ( i + 1 ) + (( name )?( '.' + name ):( '' )) );
});
// allow renaming of columns when the header is clicked
if( options.renameColumns ){
$headers.addClass( RENAMABLE_HEADER_CLASS )
.click( function renameColumn(){
// prompt for new name
var $this = $( this ),
index = $this.index() + ( options.includePrompts? 0: 1 ),
prevName = $this.data( COLUMN_NAME_DATA_KEY ),
newColumnName = prompt( 'New column name:', prevName );
if( newColumnName !== null && newColumnName !== prevName ){
// set the new text and data
$this.text( index + ( newColumnName?( '.' + newColumnName ):'' ) )
.data( COLUMN_NAME_DATA_KEY, newColumnName )
.attr( 'data-', COLUMN_NAME_DATA_KEY, newColumnName );
// fire event for new column names
var columnNames = jQuery.makeArray(
$this.parent().children( 'th:not(.top-left)' ).map( function(){
return $( this ).data( COLUMN_NAME_DATA_KEY );
}));
$this.parents( '.peek' ).trigger( RENAME_EVENT, columnNames );
}
});
}
// build a row for each control
options.controls.forEach( function( control, i ){
validateControl( control );
var $controlRow = buildControlRow( columnCount, control, options.includePrompts );
$peektable.find( 'tbody' ).append( $controlRow );
});
return this;
}
// ........................................................................
// as jq plugin
jQuery.fn.extend({
peekControl : function $peekControl( options ){
return this.map( function(){
return peekControl.call( this, options );
});
}
});
}());
File diff suppressed because one or more lines are too long
+11
View File
@@ -1396,6 +1396,17 @@ div.unified-panel-body{position:absolute;top:30px;bottom:0;width:100%}
.pagination-scroll-container .pagination-page-list>li:first-child>a,.pagination-scroll-container .pagination-page-list>li:first-child>span{border-radius:0px;border-left:0px}
.pagination-scroll-container .pagination-page-list>li:last-child>a,.pagination-scroll-container .pagination-page-list>li:last-child>span{border-radius:0px}
.pagination-scroll-container .pagination-page-list>li>a{float:none;position:static;border:1px solid #BFBFBF;border-width:0px 0px 0px 1px}
.peek-control{border-radius:3px;border:1px solid #5f6990}
.peek-control td,th{padding:4px 10px 4px 4px}
.peek-control th:last-child{width:100%}
.peek-control .top-left{width:10%;white-space:normal;vertical-align:top;text-align:right;font-family:"Lucida Grande",verdana,arial,helvetica,sans-serif;font-weight:normal}
.peek-control .renamable-header:hover{background-color:black}
.peek-control .control td.control-prompt{background-color:#5f6990;padding:0px 4px 0px 8px;text-align:right;color:white}
.peek-control .control td{padding:1px;font-family:"Lucida Grande",verdana,arial,helvetica,sans-serif;color:grey}
.peek-control .control td .button{min-width:28px;border:1px solid grey;border-radius:3px;padding:4px;color:grey}
.peek-control .control td:hover .button{background-color:#EEE;border:1px solid black;cursor:pointer;color:black}
.peek-control .control td.disabled .button,.peek-control .control td.disabled:hover .button{background-color:transparent;border:1px solid #CCC;cursor:not-allowed;color:#CCC}
.peek-control .control td.selected .button{background-color:black;border:1px solid black;color:white}
div.metadataForm{border:solid #aaaaaa 1px}
div.metadataFormTitle{font-weight:bold;padding:5px;padding-left:10px;padding-right:10px;background:#cccccc;background-repeat:repeat-x;background-position:top;border-bottom:solid #aaaaaa 1px}
div.metadataFormBody{background:#FFFFFF;padding:5px 0}
+70
View File
@@ -544,6 +544,76 @@ div.unified-panel-body {
}
// ---------------------------------------------------------------------------- peek-based column chooser
.peek-control {
border-radius: 3px;
border: 1px solid rgb(95, 105, 144);
}
.peek-control td, th {
padding: 4px 10px 4px 4px;
}
.peek-control th:last-child {
width: 100%;
}
.peek-control .top-left {
width: 10%;
white-space: normal;
vertical-align: top;
text-align: right;
font-family: "Lucida Grande", verdana, arial, helvetica, sans-serif;
font-weight: normal;
}
.peek-control .renamable-header:hover {
background-color: black;
}
.peek-control .control td.control-prompt {
background-color: rgb(95, 105, 144);
padding: 0px 4px 0px 8px;
text-align: right;
color: white;
}
.peek-control .control td {
padding: 1px;
font-family: "Lucida Grande", verdana, arial, helvetica, sans-serif;
color: grey;
}
.peek-control .control td .button {
min-width: 28px;
border: 1px solid grey;
border-radius: 3px;
padding: 4px;
color: grey;
}
.peek-control .control td:hover .button {
background-color: #EEE;
border: 1px solid black;
cursor: pointer;
color: black;
}
.peek-control .control td.disabled .button,
.peek-control .control td.disabled:hover .button {
background-color: transparent;
border: 1px solid #CCC;
cursor: not-allowed;
color: #CCC;
}
.peek-control .control td.selected .button {
background-color: black;
border: 1px solid black;
color: white;
}
// ==== Tool form styles ====
div.metadataForm {