PageRenderTime 59ms CodeModel.GetById 22ms RepoModel.GetById 0ms app.codeStats 1ms

/static/scripts/mvc/collection/paired-collection-creator.js

https://bitbucket.org/afgane/galaxy-central
JavaScript | 1719 lines | 1278 code | 168 blank | 273 comment | 139 complexity | 12cb613a302b1ed9ad82d795b1d3994a MD5 | raw file
Possible License(s): CC-BY-3.0

Large files files are truncated, but you can click here to view the full file

  1. define([
  2. "utils/levenshtein",
  3. "utils/natural-sort",
  4. "mvc/base-mvc",
  5. "utils/localization"
  6. ], function( levenshteinDistance, naturalSort, baseMVC, _l ){
  7. /* ============================================================================
  8. TODO:
  9. PROGRAMMATICALLY:
  10. currPanel.once( 'rendered', function(){
  11. currPanel.showSelectors();
  12. currPanel.selectAll();
  13. _.last( currPanel.actionsPopup.options ).func();
  14. });
  15. ============================================================================ */
  16. /** A view for paired datasets in the collections creator.
  17. */
  18. var PairView = Backbone.View.extend( baseMVC.LoggableMixin ).extend({
  19. tagName : 'li',
  20. className : 'dataset paired',
  21. initialize : function( attributes ){
  22. this.pair = attributes.pair || {};
  23. },
  24. template : _.template([
  25. '<span class="forward-dataset-name flex-column"><%= pair.forward.name %></span>',
  26. '<span class="pair-name-column flex-column">',
  27. '<span class="pair-name"><%= pair.name %></span>',
  28. '</span>',
  29. '<span class="reverse-dataset-name flex-column"><%= pair.reverse.name %></span>'
  30. ].join('')),
  31. render : function(){
  32. this.$el
  33. .attr( 'draggable', true )
  34. .data( 'pair', this.pair )
  35. .html( this.template({ pair: this.pair }) )
  36. .addClass( 'flex-column-container' );
  37. return this;
  38. },
  39. events : {
  40. 'dragstart' : '_dragstart',
  41. 'dragend' : '_dragend',
  42. 'dragover' : '_sendToParent',
  43. 'drop' : '_sendToParent'
  44. },
  45. /** dragging pairs for re-ordering */
  46. _dragstart : function( ev ){
  47. ev.currentTarget.style.opacity = '0.4';
  48. if( ev.originalEvent ){ ev = ev.originalEvent; }
  49. ev.dataTransfer.effectAllowed = 'move';
  50. ev.dataTransfer.setData( 'text/plain', JSON.stringify( this.pair ) );
  51. this.$el.parent().trigger( 'pair.dragstart', [ this ] );
  52. },
  53. /** dragging pairs for re-ordering */
  54. _dragend : function( ev ){
  55. ev.currentTarget.style.opacity = '1.0';
  56. this.$el.parent().trigger( 'pair.dragend', [ this ] );
  57. },
  58. /** manually bubble up an event to the parent/container */
  59. _sendToParent : function( ev ){
  60. this.$el.parent().trigger( ev );
  61. },
  62. /** string rep */
  63. toString : function(){
  64. return 'PairView(' + this.pair.name + ')';
  65. }
  66. });
  67. // ============================================================================
  68. /** returns an autopair function that uses the provided options.match function */
  69. function autoPairFnBuilder( options ){
  70. options = options || {};
  71. options.createPair = options.createPair || function _defaultCreatePair( params ){
  72. this.debug( 'creating pair:', params.listA[ params.indexA ].name, params.listB[ params.indexB ].name );
  73. params = params || {};
  74. return this._pair(
  75. params.listA.splice( params.indexA, 1 )[0],
  76. params.listB.splice( params.indexB, 1 )[0],
  77. { silent: true }
  78. );
  79. };
  80. // compile these here outside of the loop
  81. var _regexps = [];
  82. function getRegExps(){
  83. if( !_regexps.length ){
  84. _regexps = [
  85. new RegExp( this.filters[0] ),
  86. new RegExp( this.filters[1] )
  87. ];
  88. }
  89. return _regexps;
  90. }
  91. // mangle params as needed
  92. options.preprocessMatch = options.preprocessMatch || function _defaultPreprocessMatch( params ){
  93. var regexps = getRegExps.call( this );
  94. return _.extend( params, {
  95. matchTo : params.matchTo.name.replace( regexps[0], '' ),
  96. possible : params.possible.name.replace( regexps[1], '' )
  97. });
  98. };
  99. return function _strategy( params ){
  100. this.debug( 'autopair _strategy ---------------------------' );
  101. params = params || {};
  102. var listA = params.listA,
  103. listB = params.listB,
  104. indexA = 0, indexB,
  105. bestMatch = {
  106. score : 0.0,
  107. index : null
  108. },
  109. paired = [];
  110. //console.debug( 'params:', JSON.stringify( params, null, ' ' ) );
  111. this.debug( 'starting list lens:', listA.length, listB.length );
  112. this.debug( 'bestMatch (starting):', JSON.stringify( bestMatch, null, ' ' ) );
  113. while( indexA < listA.length ){
  114. var matchTo = listA[ indexA ];
  115. bestMatch.score = 0.0;
  116. for( indexB=0; indexB<listB.length; indexB++ ){
  117. var possible = listB[ indexB ];
  118. this.debug( indexA + ':' + matchTo.name );
  119. this.debug( indexB + ':' + possible.name );
  120. // no matching with self
  121. if( listA[ indexA ] !== listB[ indexB ] ){
  122. bestMatch = options.match.call( this, options.preprocessMatch.call( this, {
  123. matchTo : matchTo,
  124. possible: possible,
  125. index : indexB,
  126. bestMatch : bestMatch
  127. }));
  128. this.debug( 'bestMatch:', JSON.stringify( bestMatch, null, ' ' ) );
  129. if( bestMatch.score === 1.0 ){
  130. this.debug( 'breaking early due to perfect match' );
  131. break;
  132. }
  133. }
  134. }
  135. var scoreThreshold = options.scoreThreshold.call( this );
  136. this.debug( 'scoreThreshold:', scoreThreshold );
  137. this.debug( 'bestMatch.score:', bestMatch.score );
  138. if( bestMatch.score >= scoreThreshold ){
  139. this.debug( 'creating pair' );
  140. paired.push( options.createPair.call( this, {
  141. listA : listA,
  142. indexA : indexA,
  143. listB : listB,
  144. indexB : bestMatch.index
  145. }));
  146. this.debug( 'list lens now:', listA.length, listB.length );
  147. } else {
  148. indexA += 1;
  149. }
  150. if( !listA.length || !listB.length ){
  151. return paired;
  152. }
  153. }
  154. this.debug( 'paired:', JSON.stringify( paired, null, ' ' ) );
  155. this.debug( 'autopair _strategy ---------------------------' );
  156. return paired;
  157. };
  158. }
  159. // ============================================================================
  160. /** An interface for building collections of paired datasets.
  161. */
  162. var PairedCollectionCreator = Backbone.View.extend( baseMVC.LoggableMixin ).extend({
  163. className: 'collection-creator flex-row-container',
  164. /** set up initial options, instance vars, behaviors, and autopair (if set to do so) */
  165. initialize : function( attributes ){
  166. this.metric( 'PairedCollectionCreator.initialize', attributes );
  167. //this.debug( '-- PairedCollectionCreator:', attributes );
  168. attributes = _.defaults( attributes, {
  169. datasets : [],
  170. filters : this.DEFAULT_FILTERS,
  171. automaticallyPair : true,
  172. strategy : 'lcs',
  173. matchPercentage : 0.9,
  174. twoPassAutopairing : true
  175. });
  176. /** unordered, original list */
  177. this.initialList = attributes.datasets;
  178. /** is this from a history? if so, what's its id? */
  179. this.historyId = attributes.historyId;
  180. /** which filters should be used initially? (String[2] or name in commonFilters) */
  181. this.filters = this.commonFilters[ attributes.filters ] || this.commonFilters[ this.DEFAULT_FILTERS ];
  182. if( _.isArray( attributes.filters ) ){
  183. this.filters = attributes.filters;
  184. }
  185. /** try to auto pair the unpaired datasets on load? */
  186. this.automaticallyPair = attributes.automaticallyPair;
  187. /** what method to use for auto pairing (will be passed aggression level) */
  188. this.strategy = this.strategies[ attributes.strategy ] || this.strategies[ this.DEFAULT_STRATEGY ];
  189. if( _.isFunction( attributes.strategy ) ){
  190. this.strategy = attributes.strategy;
  191. }
  192. /** distance/mismatch level allowed for autopairing */
  193. this.matchPercentage = attributes.matchPercentage;
  194. /** try to autopair using simple first, then this.strategy on the remainder */
  195. this.twoPassAutopairing = attributes.twoPassAutopairing;
  196. /** remove file extensions (\.*) from created pair names? */
  197. this.removeExtensions = true;
  198. //this.removeExtensions = false;
  199. /** fn to call when the cancel button is clicked (scoped to this) - if falsy, no btn is displayed */
  200. this.oncancel = attributes.oncancel;
  201. /** fn to call when the collection is created (scoped to this) */
  202. this.oncreate = attributes.oncreate;
  203. /** fn to call when the cancel button is clicked (scoped to this) - if falsy, no btn is displayed */
  204. this.autoscrollDist = attributes.autoscrollDist || 24;
  205. /** is the unpaired panel shown? */
  206. this.unpairedPanelHidden = false;
  207. /** is the paired panel shown? */
  208. this.pairedPanelHidden = false;
  209. /** DOM elements currently being dragged */
  210. this.$dragging = null;
  211. this._setUpBehaviors();
  212. this._dataSetUp();
  213. },
  214. /** map of common filter pairs by name */
  215. commonFilters : {
  216. illumina : [ '_1', '_2' ],
  217. Rs : [ '_R1', '_R2' ]
  218. },
  219. /** which commonFilter to use by default */
  220. DEFAULT_FILTERS : 'illumina',
  221. /** map of name->fn for autopairing */
  222. strategies : {
  223. 'simple' : 'autopairSimple',
  224. 'lcs' : 'autopairLCS',
  225. 'levenshtein' : 'autopairLevenshtein'
  226. },
  227. /** default autopair strategy name */
  228. DEFAULT_STRATEGY : 'lcs',
  229. // ------------------------------------------------------------------------ process raw list
  230. /** set up main data: cache initialList, sort, and autopair */
  231. _dataSetUp : function(){
  232. //this.debug( '-- _dataSetUp' );
  233. this.paired = [];
  234. this.unpaired = [];
  235. this.selectedIds = [];
  236. // sort initial list, add ids if needed, and save new working copy to unpaired
  237. this._sortInitialList();
  238. this._ensureIds();
  239. this.unpaired = this.initialList.slice( 0 );
  240. if( this.automaticallyPair ){
  241. this.autoPair();
  242. this.once( 'rendered:initial', function(){
  243. this.trigger( 'autopair' );
  244. });
  245. }
  246. },
  247. /** sort initial list */
  248. _sortInitialList : function(){
  249. //this.debug( '-- _sortInitialList' );
  250. this._sortDatasetList( this.initialList );
  251. },
  252. /** sort a list of datasets */
  253. _sortDatasetList : function( list ){
  254. // currently only natural sort by name
  255. list.sort( function( a, b ){ return naturalSort( a.name, b.name ); });
  256. return list;
  257. },
  258. /** add ids to dataset objs in initial list if none */
  259. _ensureIds : function(){
  260. this.initialList.forEach( function( dataset ){
  261. if( !dataset.hasOwnProperty( 'id' ) ){
  262. dataset.id = _.uniqueId();
  263. }
  264. });
  265. return this.initialList;
  266. },
  267. /** split initial list into two lists, those that pass forward filters & those passing reverse */
  268. _splitByFilters : function(){
  269. var regexFilters = this.filters.map( function( stringFilter ){
  270. return new RegExp( stringFilter );
  271. }),
  272. split = [ [], [] ];
  273. function _filter( unpaired, filter ){
  274. return filter.test( unpaired.name );
  275. //return dataset.name.indexOf( filter ) >= 0;
  276. }
  277. this.unpaired.forEach( function _filterEach( unpaired ){
  278. // 90% of the time this seems to work, but:
  279. //TODO: this treats *all* strings as regex which may confuse people - possibly check for // surrounding?
  280. // would need explanation in help as well
  281. regexFilters.forEach( function( filter, i ){
  282. if( _filter( unpaired, filter ) ){
  283. split[i].push( unpaired );
  284. }
  285. });
  286. });
  287. return split;
  288. },
  289. /** add a dataset to the unpaired list in it's proper order */
  290. _addToUnpaired : function( dataset ){
  291. // currently, unpaired is natural sorted by name, use binary search to find insertion point
  292. var binSearchSortedIndex = function( low, hi ){
  293. if( low === hi ){ return low; }
  294. var mid = Math.floor( ( hi - low ) / 2 ) + low,
  295. compared = naturalSort( dataset.name, this.unpaired[ mid ].name );
  296. if( compared < 0 ){
  297. return binSearchSortedIndex( low, mid );
  298. } else if( compared > 0 ){
  299. return binSearchSortedIndex( mid + 1, hi );
  300. }
  301. // walk the equal to find the last
  302. while( this.unpaired[ mid ] && this.unpaired[ mid ].name === dataset.name ){ mid++; }
  303. return mid;
  304. }.bind( this );
  305. this.unpaired.splice( binSearchSortedIndex( 0, this.unpaired.length ), 0, dataset );
  306. },
  307. // ------------------------------------------------------------------------ auto pairing
  308. /** two passes to automatically create pairs:
  309. * use both simpleAutoPair, then the fn mentioned in strategy
  310. */
  311. autoPair : function( strategy ){
  312. // split first using exact matching
  313. var split = this._splitByFilters(),
  314. paired = [];
  315. if( this.twoPassAutopairing ){
  316. paired = this.autopairSimple({
  317. listA : split[0],
  318. listB : split[1]
  319. });
  320. split = this._splitByFilters();
  321. }
  322. // uncomment to see printlns while running tests
  323. //this.debug = function(){ console.log.apply( console, arguments ); };
  324. // then try the remainder with something less strict
  325. strategy = strategy || this.strategy;
  326. split = this._splitByFilters();
  327. paired = paired.concat( this[ strategy ].call( this, {
  328. listA : split[0],
  329. listB : split[1]
  330. }));
  331. return paired;
  332. },
  333. /** autopair by exact match */
  334. autopairSimple : autoPairFnBuilder({
  335. scoreThreshold: function(){ return 1.0; },
  336. match : function _match( params ){
  337. params = params || {};
  338. if( params.matchTo === params.possible ){
  339. return {
  340. index: params.index,
  341. score: 1.0
  342. };
  343. }
  344. return params.bestMatch;
  345. }
  346. }),
  347. /** autopair by levenshtein edit distance scoring */
  348. autopairLevenshtein : autoPairFnBuilder({
  349. scoreThreshold: function(){ return this.matchPercentage; },
  350. match : function _matches( params ){
  351. params = params || {};
  352. var distance = levenshteinDistance( params.matchTo, params.possible ),
  353. score = 1.0 - ( distance / ( Math.max( params.matchTo.length, params.possible.length ) ) );
  354. if( score > params.bestMatch.score ){
  355. return {
  356. index: params.index,
  357. score: score
  358. };
  359. }
  360. return params.bestMatch;
  361. }
  362. }),
  363. /** autopair by longest common substrings scoring */
  364. autopairLCS : autoPairFnBuilder({
  365. scoreThreshold: function(){ return this.matchPercentage; },
  366. match : function _matches( params ){
  367. params = params || {};
  368. var match = this._naiveStartingAndEndingLCS( params.matchTo, params.possible ).length,
  369. score = match / ( Math.max( params.matchTo.length, params.possible.length ) );
  370. if( score > params.bestMatch.score ){
  371. return {
  372. index: params.index,
  373. score: score
  374. };
  375. }
  376. return params.bestMatch;
  377. }
  378. }),
  379. /** return the concat'd longest common prefix and suffix from two strings */
  380. _naiveStartingAndEndingLCS : function( s1, s2 ){
  381. var fwdLCS = '',
  382. revLCS = '',
  383. i = 0, j = 0;
  384. while( i < s1.length && i < s2.length ){
  385. if( s1[ i ] !== s2[ i ] ){
  386. break;
  387. }
  388. fwdLCS += s1[ i ];
  389. i += 1;
  390. }
  391. if( i === s1.length ){ return s1; }
  392. if( i === s2.length ){ return s2; }
  393. i = ( s1.length - 1 );
  394. j = ( s2.length - 1 );
  395. while( i >= 0 && j >= 0 ){
  396. if( s1[ i ] !== s2[ j ] ){
  397. break;
  398. }
  399. revLCS = [ s1[ i ], revLCS ].join( '' );
  400. i -= 1;
  401. j -= 1;
  402. }
  403. return fwdLCS + revLCS;
  404. },
  405. // ------------------------------------------------------------------------ pairing / unpairing
  406. /** create a pair from fwd and rev, removing them from unpaired, and placing the new pair in paired */
  407. _pair : function( fwd, rev, options ){
  408. options = options || {};
  409. //this.debug( '_pair:', fwd, rev );
  410. var pair = this._createPair( fwd, rev, options.name );
  411. this.paired.push( pair );
  412. this.unpaired = _.without( this.unpaired, fwd, rev );
  413. if( !options.silent ){
  414. this.trigger( 'pair:new', pair );
  415. }
  416. return pair;
  417. },
  418. /** create a pair Object from fwd and rev, adding the name attribute (will guess if not given) */
  419. _createPair : function( fwd, rev, name ){
  420. // ensure existance and don't pair something with itself
  421. if( !( fwd && rev ) || ( fwd === rev ) ){
  422. throw new Error( 'Bad pairing: ' + [ JSON.stringify( fwd ), JSON.stringify( rev ) ] );
  423. }
  424. name = name || this._guessNameForPair( fwd, rev );
  425. return { forward : fwd, name : name, reverse : rev };
  426. },
  427. /** try to find a good pair name for the given fwd and rev datasets */
  428. _guessNameForPair : function( fwd, rev, removeExtensions ){
  429. removeExtensions = ( removeExtensions !== undefined )?( removeExtensions ):( this.removeExtensions );
  430. var fwdName = fwd.name,
  431. revName = rev.name,
  432. lcs = this._naiveStartingAndEndingLCS(
  433. fwdName.replace( this.filters[0], '' ),
  434. revName.replace( this.filters[1], '' )
  435. );
  436. if( removeExtensions ){
  437. var lastDotIndex = lcs.lastIndexOf( '.' );
  438. if( lastDotIndex > 0 ){
  439. var extension = lcs.slice( lastDotIndex, lcs.length );
  440. lcs = lcs.replace( extension, '' );
  441. fwdName = fwdName.replace( extension, '' );
  442. revName = revName.replace( extension, '' );
  443. }
  444. }
  445. return lcs || ( fwdName + ' & ' + revName );
  446. },
  447. /** unpair a pair, removing it from paired, and adding the fwd,rev datasets back into unpaired */
  448. _unpair : function( pair, options ){
  449. options = options || {};
  450. if( !pair ){
  451. throw new Error( 'Bad pair: ' + JSON.stringify( pair ) );
  452. }
  453. this.paired = _.without( this.paired, pair );
  454. this._addToUnpaired( pair.forward );
  455. this._addToUnpaired( pair.reverse );
  456. if( !options.silent ){
  457. this.trigger( 'pair:unpair', [ pair ] );
  458. }
  459. return pair;
  460. },
  461. /** unpair all paired datasets */
  462. unpairAll : function(){
  463. var pairs = [];
  464. while( this.paired.length ){
  465. pairs.push( this._unpair( this.paired[ 0 ], { silent: true }) );
  466. }
  467. this.trigger( 'pair:unpair', pairs );
  468. },
  469. // ------------------------------------------------------------------------ API
  470. /** convert a pair into JSON compatible with the collections API */
  471. _pairToJSON : function( pair, src ){
  472. src = src || 'hda';
  473. //TODO: consider making this the pair structure when created instead
  474. return {
  475. collection_type : 'paired',
  476. src : 'new_collection',
  477. name : pair.name,
  478. element_identifiers : [{
  479. name : 'forward',
  480. id : pair.forward.id,
  481. src : src
  482. }, {
  483. name : 'reverse',
  484. id : pair.reverse.id,
  485. src : src
  486. }]
  487. };
  488. },
  489. /** create the collection via the API
  490. * @returns {jQuery.xhr Object} the jquery ajax request
  491. */
  492. createList : function( name ){
  493. var creator = this,
  494. url = '/api/histories/' + this.historyId + '/contents/dataset_collections';
  495. //TODO: use ListPairedCollection.create()
  496. var ajaxData = {
  497. type : 'dataset_collection',
  498. collection_type : 'list:paired',
  499. name : _.escape( name || creator.$( '.collection-name' ).val() ),
  500. element_identifiers : creator.paired.map( function( pair ){
  501. return creator._pairToJSON( pair );
  502. })
  503. };
  504. //this.debug( JSON.stringify( ajaxData ) );
  505. return jQuery.ajax( url, {
  506. type : 'POST',
  507. contentType : 'application/json',
  508. dataType : 'json',
  509. data : JSON.stringify( ajaxData )
  510. })
  511. .fail( function( xhr, status, message ){
  512. creator._ajaxErrHandler( xhr, status, message );
  513. })
  514. .done( function( response, message, xhr ){
  515. //this.info( 'ok', response, message, xhr );
  516. creator.trigger( 'collection:created', response, message, xhr );
  517. creator.metric( 'collection:created', response );
  518. if( typeof creator.oncreate === 'function' ){
  519. creator.oncreate.call( this, response, message, xhr );
  520. }
  521. });
  522. },
  523. /** handle ajax errors with feedback and details to the user (if available) */
  524. _ajaxErrHandler : function( xhr, status, message ){
  525. this.error( xhr, status, message );
  526. var content = _l( 'An error occurred while creating this collection' );
  527. if( xhr ){
  528. if( xhr.readyState === 0 && xhr.status === 0 ){
  529. content += ': ' + _l( 'Galaxy could not be reached and may be updating.' )
  530. + _l( ' Try again in a few minutes.' );
  531. } else if( xhr.responseJSON ){
  532. content += '<br /><pre>' + JSON.stringify( xhr.responseJSON ) + '</pre>';
  533. } else {
  534. content += ': ' + message;
  535. }
  536. }
  537. creator._showAlert( content, 'alert-danger' );
  538. },
  539. // ------------------------------------------------------------------------ rendering
  540. /** render the entire interface */
  541. render : function( speed, callback ){
  542. //this.debug( '-- _render' );
  543. //this.$el.empty().html( PairedCollectionCreator.templates.main() );
  544. this.$el.empty().html( PairedCollectionCreator.templates.main() );
  545. this._renderHeader( speed );
  546. this._renderMiddle( speed );
  547. this._renderFooter( speed );
  548. this._addPluginComponents();
  549. this.trigger( 'rendered', this );
  550. return this;
  551. },
  552. /** render the header section */
  553. _renderHeader : function( speed, callback ){
  554. //this.debug( '-- _renderHeader' );
  555. var $header = this.$( '.header' ).empty().html( PairedCollectionCreator.templates.header() )
  556. .find( '.help-content' ).prepend( $( PairedCollectionCreator.templates.helpContent() ) );
  557. this._renderFilters();
  558. return $header;
  559. },
  560. /** fill the filter inputs with the filter values */
  561. _renderFilters : function(){
  562. return this.$( '.forward-column .column-header input' ).val( this.filters[0] )
  563. .add( this.$( '.reverse-column .column-header input' ).val( this.filters[1] ) );
  564. },
  565. /** render the middle including unpaired and paired sections (which may be hidden) */
  566. _renderMiddle : function( speed, callback ){
  567. var $middle = this.$( '.middle' ).empty().html( PairedCollectionCreator.templates.middle() );
  568. // (re-) hide the un/paired panels based on instance vars
  569. if( this.unpairedPanelHidden ){
  570. this.$( '.unpaired-columns' ).hide();
  571. } else if( this.pairedPanelHidden ){
  572. this.$( '.paired-columns' ).hide();
  573. }
  574. this._renderUnpaired();
  575. this._renderPaired();
  576. return $middle;
  577. },
  578. /** render the unpaired section, showing datasets accrd. to filters, update the unpaired counts */
  579. _renderUnpaired : function( speed, callback ){
  580. //this.debug( '-- _renderUnpaired' );
  581. var creator = this,
  582. $fwd, $rev, $prd = [],
  583. split = this._splitByFilters();
  584. // update unpaired counts
  585. this.$( '.forward-column .title' )
  586. .text([ split[0].length, _l( 'unpaired forward' ) ].join( ' ' ));
  587. this.$( '.forward-column .unpaired-info' )
  588. .text( this._renderUnpairedDisplayStr( this.unpaired.length - split[0].length ) );
  589. this.$( '.reverse-column .title' )
  590. .text([ split[1].length, _l( 'unpaired reverse' ) ].join( ' ' ));
  591. this.$( '.reverse-column .unpaired-info' )
  592. .text( this._renderUnpairedDisplayStr( this.unpaired.length - split[1].length ) );
  593. this.$( '.unpaired-columns .column-datasets' ).empty();
  594. // show/hide the auto pair button if any unpaired are left
  595. this.$( '.autopair-link' ).toggle( this.unpaired.length !== 0 );
  596. if( this.unpaired.length === 0 ){
  597. this._renderUnpairedEmpty();
  598. return;
  599. }
  600. // create the dataset dom arrays
  601. $rev = split[1].map( function( dataset, i ){
  602. // if there'll be a fwd dataset across the way, add a button to pair the row
  603. if( ( split[0][ i ] !== undefined )
  604. && ( split[0][ i ] !== dataset ) ){
  605. $prd.push( creator._renderPairButton() );
  606. }
  607. return creator._renderUnpairedDataset( dataset );
  608. });
  609. $fwd = split[0].map( function( dataset ){
  610. return creator._renderUnpairedDataset( dataset );
  611. });
  612. if( !$fwd.length && !$rev.length ){
  613. this._renderUnpairedNotShown();
  614. return;
  615. }
  616. // add to appropo cols
  617. //TODO: not the best way to render - consider rendering the entire unpaired-columns section in a fragment
  618. // and swapping out that
  619. this.$( '.unpaired-columns .forward-column .column-datasets' ).append( $fwd )
  620. .add( this.$( '.unpaired-columns .paired-column .column-datasets' ).append( $prd ) )
  621. .add( this.$( '.unpaired-columns .reverse-column .column-datasets' ).append( $rev ) );
  622. this._adjUnpairedOnScrollbar();
  623. },
  624. /** return a string to display the count of filtered out datasets */
  625. _renderUnpairedDisplayStr : function( numFiltered ){
  626. return [ '(', numFiltered, ' ', _l( 'filtered out' ), ')' ].join('');
  627. },
  628. /** return an unattached jQuery DOM element to represent an unpaired dataset */
  629. _renderUnpairedDataset : function( dataset ){
  630. //TODO: to underscore template
  631. return $( '<li/>')
  632. .attr( 'id', 'dataset-' + dataset.id )
  633. .addClass( 'dataset unpaired' )
  634. .attr( 'draggable', true )
  635. .addClass( dataset.selected? 'selected': '' )
  636. .append( $( '<span/>' ).addClass( 'dataset-name' ).text( dataset.name ) )
  637. //??
  638. .data( 'dataset', dataset );
  639. },
  640. /** render the button that may go between unpaired datasets, allowing the user to pair a row */
  641. _renderPairButton : function(){
  642. //TODO: *not* a dataset - don't pretend like it is
  643. return $( '<li/>').addClass( 'dataset unpaired' )
  644. .append( $( '<span/>' ).addClass( 'dataset-name' ).text( _l( 'Pair these datasets' ) ) );
  645. },
  646. /** a message to display when no unpaired left */
  647. _renderUnpairedEmpty : function(){
  648. //this.debug( '-- renderUnpairedEmpty' );
  649. var $msg = $( '<div class="empty-message"></div>' )
  650. .text( '(' + _l( 'no remaining unpaired datasets' ) + ')' );
  651. this.$( '.unpaired-columns .paired-column .column-datasets' ).empty().prepend( $msg );
  652. return $msg;
  653. },
  654. /** a message to display when no unpaired can be shown with the current filters */
  655. _renderUnpairedNotShown : function(){
  656. //this.debug( '-- renderUnpairedEmpty' );
  657. var $msg = $( '<div class="empty-message"></div>' )
  658. .text( '(' + _l( 'no datasets were found matching the current filters' ) + ')' );
  659. this.$( '.unpaired-columns .paired-column .column-datasets' ).empty().prepend( $msg );
  660. return $msg;
  661. },
  662. /** try to detect if the unpaired section has a scrollbar and adjust left column for better centering of all */
  663. _adjUnpairedOnScrollbar : function(){
  664. var $unpairedColumns = this.$( '.unpaired-columns' ).last(),
  665. $firstDataset = this.$( '.unpaired-columns .reverse-column .dataset' ).first();
  666. if( !$firstDataset.size() ){ return; }
  667. var ucRight = $unpairedColumns.offset().left + $unpairedColumns.outerWidth(),
  668. dsRight = $firstDataset.offset().left + $firstDataset.outerWidth(),
  669. rightDiff = Math.floor( ucRight ) - Math.floor( dsRight );
  670. //this.debug( 'rightDiff:', ucRight, '-', dsRight, '=', rightDiff );
  671. this.$( '.unpaired-columns .forward-column' )
  672. .css( 'margin-left', ( rightDiff > 0 )? rightDiff: 0 );
  673. },
  674. /** render the paired section and update counts of paired datasets */
  675. _renderPaired : function( speed, callback ){
  676. //this.debug( '-- _renderPaired' );
  677. this.$( '.paired-column-title .title' ).text([ this.paired.length, _l( 'paired' ) ].join( ' ' ) );
  678. // show/hide the unpair all link
  679. this.$( '.unpair-all-link' ).toggle( this.paired.length !== 0 );
  680. if( this.paired.length === 0 ){
  681. this._renderPairedEmpty();
  682. return;
  683. //TODO: would be best to return here (the $columns)
  684. } else {
  685. // show/hide 'remove extensions link' when any paired and they seem to have extensions
  686. this.$( '.remove-extensions-link' ).show();
  687. }
  688. this.$( '.paired-columns .column-datasets' ).empty();
  689. var creator = this;
  690. this.paired.forEach( function( pair, i ){
  691. //TODO: cache these?
  692. var pairView = new PairView({ pair: pair });
  693. creator.$( '.paired-columns .column-datasets' )
  694. .append( pairView.render().$el )
  695. .append([
  696. '<button class="unpair-btn">',
  697. '<span class="fa fa-unlink" title="', _l( 'Unpair' ), '"></span>',
  698. '</button>'
  699. ].join( '' ));
  700. });
  701. },
  702. /** a message to display when none paired */
  703. _renderPairedEmpty : function(){
  704. var $msg = $( '<div class="empty-message"></div>' )
  705. .text( '(' + _l( 'no paired datasets yet' ) + ')' );
  706. this.$( '.paired-columns .column-datasets' ).empty().prepend( $msg );
  707. return $msg;
  708. },
  709. /** render the footer, completion controls, and cancel controls */
  710. _renderFooter : function( speed, callback ){
  711. var $footer = this.$( '.footer' ).empty().html( PairedCollectionCreator.templates.footer() );
  712. this.$( '.remove-extensions' ).prop( 'checked', this.removeExtensions );
  713. if( typeof this.oncancel === 'function' ){
  714. this.$( '.cancel-create.btn' ).show();
  715. }
  716. return $footer;
  717. },
  718. /** add any jQuery/bootstrap/custom plugins to elements rendered */
  719. _addPluginComponents : function(){
  720. this._chooseFiltersPopover( '.choose-filters-link' );
  721. this.$( '.help-content i' ).hoverhighlight( '.collection-creator', 'rgba( 64, 255, 255, 1.0 )' );
  722. },
  723. /** build a filter selection popover allowing selection of common filter pairs */
  724. _chooseFiltersPopover : function( selector ){
  725. function filterChoice( val1, val2 ){
  726. return [
  727. '<button class="filter-choice btn" ',
  728. 'data-forward="', val1, '" data-reverse="', val2, '">',
  729. _l( 'Forward' ), ': ', val1, ', ',
  730. _l( 'Reverse' ), ': ', val2,
  731. '</button>'
  732. ].join('');
  733. }
  734. var $popoverContent = $( _.template([
  735. '<div class="choose-filters">',
  736. '<div class="help">',
  737. _l( 'Choose from the following filters to change which unpaired reads are shown in the display' ),
  738. ':</div>',
  739. _.values( this.commonFilters ).map( function( filterSet ){
  740. return filterChoice( filterSet[0], filterSet[1] );
  741. }).join( '' ),
  742. '</div>'
  743. ].join(''))({}));
  744. return this.$( selector ).popover({
  745. container : '.collection-creator',
  746. placement : 'bottom',
  747. html : true,
  748. //animation : false,
  749. content : $popoverContent
  750. });
  751. },
  752. /** add (or clear if clear is truthy) a validation warning to what */
  753. _validationWarning : function( what, clear ){
  754. var VALIDATION_CLASS = 'validation-warning';
  755. if( what === 'name' ){
  756. what = this.$( '.collection-name' ).add( this.$( '.collection-name-prompt' ) );
  757. this.$( '.collection-name' ).focus().select();
  758. }
  759. if( clear ){
  760. what = what || this.$( '.' + VALIDATION_CLASS );
  761. what.removeClass( VALIDATION_CLASS );
  762. } else {
  763. what.addClass( VALIDATION_CLASS );
  764. }
  765. },
  766. // ------------------------------------------------------------------------ events
  767. /** set up event handlers on self */
  768. _setUpBehaviors : function(){
  769. this.once( 'rendered', function(){
  770. this.trigger( 'rendered:initial', this );
  771. });
  772. this.on( 'pair:new', function(){
  773. //TODO: ideally only re-render the columns (or even elements) involved
  774. this._renderUnpaired();
  775. this._renderPaired();
  776. // scroll to bottom where new pairs are added
  777. //TODO: this doesn't seem to work - innerHeight sticks at 133...
  778. // may have to do with improper flex columns
  779. //var $pairedView = this.$( '.paired-columns' );
  780. //$pairedView.scrollTop( $pairedView.innerHeight() );
  781. //this.debug( $pairedView.height() )
  782. this.$( '.paired-columns' ).scrollTop( 8000000 );
  783. });
  784. this.on( 'pair:unpair', function( pairs ){
  785. //TODO: ideally only re-render the columns (or even elements) involved
  786. this._renderUnpaired();
  787. this._renderPaired();
  788. this.splitView();
  789. });
  790. this.on( 'filter-change', function(){
  791. this.filters = [
  792. this.$( '.forward-unpaired-filter input' ).val(),
  793. this.$( '.reverse-unpaired-filter input' ).val()
  794. ];
  795. this.metric( 'filter-change', this.filters );
  796. this._renderFilters();
  797. this._renderUnpaired();
  798. });
  799. this.on( 'autopair', function(){
  800. this._renderUnpaired();
  801. this._renderPaired();
  802. var message, msgClass = null;
  803. if( this.paired.length ){
  804. msgClass = 'alert-success';
  805. message = this.paired.length + ' ' + _l( 'pairs created' );
  806. if( !this.unpaired.length ){
  807. message += ': ' + _l( 'all datasets have been successfully paired' );
  808. this.hideUnpaired();
  809. this.$( '.collection-name' ).focus();
  810. }
  811. } else {
  812. message = _l( 'Could not automatically create any pairs from the given dataset names' );
  813. }
  814. this._showAlert( message, msgClass );
  815. });
  816. //this.on( 'all', function(){
  817. // this.info( arguments );
  818. //});
  819. return this;
  820. },
  821. events : {
  822. // header
  823. 'click .more-help' : '_clickMoreHelp',
  824. 'click .less-help' : '_clickLessHelp',
  825. 'click .header .alert button' : '_hideAlert',
  826. 'click .forward-column .column-title' : '_clickShowOnlyUnpaired',
  827. 'click .reverse-column .column-title' : '_clickShowOnlyUnpaired',
  828. 'click .unpair-all-link' : '_clickUnpairAll',
  829. //TODO: this seems kinda backasswards - re-sending jq event as a backbone event, can we listen directly?
  830. 'change .forward-unpaired-filter input' : function( ev ){ this.trigger( 'filter-change' ); },
  831. 'focus .forward-unpaired-filter input' : function( ev ){ $( ev.currentTarget ).select(); },
  832. 'click .autopair-link' : '_clickAutopair',
  833. 'click .choose-filters .filter-choice' : '_clickFilterChoice',
  834. 'click .clear-filters-link' : '_clearFilters',
  835. 'change .reverse-unpaired-filter input' : function( ev ){ this.trigger( 'filter-change' ); },
  836. 'focus .reverse-unpaired-filter input' : function( ev ){ $( ev.currentTarget ).select(); },
  837. // unpaired
  838. 'click .forward-column .dataset.unpaired' : '_clickUnpairedDataset',
  839. 'click .reverse-column .dataset.unpaired' : '_clickUnpairedDataset',
  840. 'click .paired-column .dataset.unpaired' : '_clickPairRow',
  841. 'click .unpaired-columns' : 'clearSelectedUnpaired',
  842. 'mousedown .unpaired-columns .dataset' : '_mousedownUnpaired',
  843. // divider
  844. 'click .paired-column-title' : '_clickShowOnlyPaired',
  845. 'mousedown .flexible-partition-drag' : '_startPartitionDrag',
  846. // paired
  847. 'click .paired-columns .dataset.paired' : 'selectPair',
  848. 'click .paired-columns' : 'clearSelectedPaired',
  849. 'click .paired-columns .pair-name' : '_clickPairName',
  850. 'click .unpair-btn' : '_clickUnpair',
  851. // paired - drop target
  852. //'dragenter .paired-columns' : '_dragenterPairedColumns',
  853. //'dragleave .paired-columns .column-datasets': '_dragleavePairedColumns',
  854. 'dragover .paired-columns .column-datasets' : '_dragoverPairedColumns',
  855. 'drop .paired-columns .column-datasets' : '_dropPairedColumns',
  856. 'pair.dragstart .paired-columns .column-datasets' : '_pairDragstart',
  857. 'pair.dragend .paired-columns .column-datasets' : '_pairDragend',
  858. // footer
  859. 'change .remove-extensions' : function( ev ){ this.toggleExtensions(); },
  860. 'change .collection-name' : '_changeName',
  861. 'keydown .collection-name' : '_nameCheckForEnter',
  862. 'click .cancel-create' : function( ev ){
  863. if( typeof this.oncancel === 'function' ){
  864. this.oncancel.call( this );
  865. }
  866. },
  867. 'click .create-collection' : '_clickCreate'//,
  868. },
  869. // ........................................................................ header
  870. /** expand help */
  871. _clickMoreHelp : function( ev ){
  872. this.$( '.main-help' ).addClass( 'expanded' );
  873. this.$( '.more-help' ).hide();
  874. },
  875. /** collapse help */
  876. _clickLessHelp : function( ev ){
  877. this.$( '.main-help' ).removeClass( 'expanded' );
  878. this.$( '.more-help' ).show();
  879. },
  880. /** show an alert on the top of the interface containing message (alertClass is bootstrap's alert-*)*/
  881. _showAlert : function( message, alertClass ){
  882. alertClass = alertClass || 'alert-danger';
  883. this.$( '.main-help' ).hide();
  884. this.$( '.header .alert' ).attr( 'class', 'alert alert-dismissable' ).addClass( alertClass ).show()
  885. .find( '.alert-message' ).html( message );
  886. },
  887. /** hide the alerts at the top */
  888. _hideAlert : function( message ){
  889. this.$( '.main-help' ).show();
  890. this.$( '.header .alert' ).hide();
  891. },
  892. /** toggle between showing only unpaired and split view */
  893. _clickShowOnlyUnpaired : function( ev ){
  894. //this.debug( 'click unpaired', ev.currentTarget );
  895. if( this.$( '.paired-columns' ).is( ':visible' ) ){
  896. this.hidePaired();
  897. } else {
  898. this.splitView();
  899. }
  900. },
  901. /** toggle between showing only paired and split view */
  902. _clickShowOnlyPaired : function( ev ){
  903. //this.debug( 'click paired' );
  904. if( this.$( '.unpaired-columns' ).is( ':visible' ) ){
  905. this.hideUnpaired();
  906. } else {
  907. this.splitView();
  908. }
  909. },
  910. /** hide unpaired, show paired */
  911. hideUnpaired : function( speed, callback ){
  912. this.unpairedPanelHidden = true;
  913. this.pairedPanelHidden = false;
  914. this._renderMiddle( speed, callback );
  915. },
  916. /** hide paired, show unpaired */
  917. hidePaired : function( speed, callback ){
  918. this.unpairedPanelHidden = false;
  919. this.pairedPanelHidden = true;
  920. this._renderMiddle( speed, callback );
  921. },
  922. /** show both paired and unpaired (splitting evenly) */
  923. splitView : function( speed, callback ){
  924. this.unpairedPanelHidden = this.pairedPanelHidden = false;
  925. this._renderMiddle( speed, callback );
  926. return this;
  927. },
  928. /** unpair all paired and do other super neat stuff which I'm not really sure about yet... */
  929. _clickUnpairAll : function( ev ){
  930. this.metric( 'unpairAll' );
  931. this.unpairAll();
  932. },
  933. /** attempt to autopair */
  934. _clickAutopair : function( ev ){
  935. var paired = this.autoPair();
  936. this.metric( 'autopair', paired.length, this.unpaired.length );
  937. this.trigger( 'autopair' );
  938. },
  939. /** set the filters based on the data attributes of the button click target */
  940. _clickFilterChoice : function( ev ){
  941. var $selected = $( ev.currentTarget );
  942. this.$( '.forward-unpaired-filter input' ).val( $selected.data( 'forward' ) );
  943. this.$( '.reverse-unpaired-filter input' ).val( $selected.data( 'reverse' ) );
  944. this._hideChooseFilters();
  945. this.trigger( 'filter-change' );
  946. },
  947. /** hide the choose filters popover */
  948. _hideChooseFilters : function(){
  949. //TODO: update bootstrap and remove the following hack
  950. // see also: https://github.com/twbs/bootstrap/issues/10260
  951. this.$( '.choose-filters-link' ).popover( 'hide' );
  952. this.$( '.popover' ).css( 'display', 'none' );
  953. },
  954. /** clear both filters */
  955. _clearFilters : function( ev ){
  956. this.$( '.forward-unpaired-filter input' ).val( '' );
  957. this.$( '.reverse-unpaired-filter input' ).val( '' );
  958. this.trigger( 'filter-change' );
  959. },
  960. // ........................................................................ unpaired
  961. /** select an unpaired dataset */
  962. _clickUnpairedDataset : function( ev ){
  963. ev.stopPropagation();
  964. return this.toggleSelectUnpaired( $( ev.currentTarget ) );
  965. },
  966. /** Toggle the selection of an unpaired dataset representation.
  967. * @param [jQuery] $dataset the unpaired dataset dom rep to select
  968. * @param [Boolean] options.force if defined, force selection based on T/F; otherwise, toggle
  969. */
  970. toggleSelectUnpaired : function( $dataset, options ){
  971. options = options || {};
  972. var dataset = $dataset.data( 'dataset' ),
  973. select = options.force !== undefined? options.force: !$dataset.hasClass( 'selected' );
  974. //this.debug( id, options.force, $dataset, dataset );
  975. if( !$dataset.size() || dataset === undefined ){ return $dataset; }
  976. if( select ){
  977. $dataset.addClass( 'selected' );
  978. if( !options.waitToPair ){
  979. this.pairAllSelected();
  980. }
  981. } else {
  982. $dataset.removeClass( 'selected' );
  983. //delete dataset.selected;
  984. }
  985. return $dataset;
  986. },
  987. /** pair all the currently selected unpaired datasets */
  988. pairAllSelected : function( options ){
  989. options = options || {};
  990. var creator = this,
  991. fwds = [],
  992. revs = [],
  993. pairs = [];
  994. creator.$( '.unpaired-columns .forward-column .dataset.selected' ).each( function(){
  995. fwds.push( $( this ).data( 'dataset' ) );
  996. });
  997. creator.$( '.unpaired-columns .reverse-column .dataset.selected' ).each( function(){
  998. revs.push( $( this ).data( 'dataset' ) );
  999. });
  1000. fwds.length = revs.length = Math.min( fwds.length, revs.length );
  1001. //this.debug( fwds );
  1002. //this.debug( revs );
  1003. fwds.forEach( function( fwd, i ){
  1004. try {
  1005. pairs.push( creator._pair( fwd, revs[i], { silent: true }) );
  1006. } catch( err ){
  1007. //TODO: preserve selected state of those that couldn't be paired
  1008. //TODO: warn that some could not be paired
  1009. creator.error( err );
  1010. }
  1011. });
  1012. if( pairs.length && !options.silent ){
  1013. this.trigger( 'pair:new', pairs );
  1014. }
  1015. return pairs;
  1016. },
  1017. /** clear the selection on all unpaired datasets */
  1018. clearSelectedUnpaired : function(){
  1019. this.$( '.unpaired-columns .dataset.selected' ).removeClass( 'selected' );
  1020. },
  1021. /** when holding down the shift key on a click, 'paint' the moused over datasets as selected */
  1022. _mousedownUnpaired : function( ev ){
  1023. if( ev.shiftKey ){
  1024. var creator = this,
  1025. $startTarget = $( ev.target ).addClass( 'selected' ),
  1026. moveListener = function( ev ){
  1027. creator.$( ev.target ).filter( '.dataset' ).addClass( 'selected' );
  1028. };
  1029. $startTarget.parent().on( 'mousemove', moveListener );
  1030. // on any mouseup, stop listening to the move and try to pair any selected
  1031. $( document ).one( 'mouseup', function( ev ){
  1032. $startTarget.parent().off( 'mousemove', moveListener );
  1033. creator.pairAllSelected();
  1034. });
  1035. }
  1036. },
  1037. /** attempt to pair two datasets directly across from one another */
  1038. _clickPairRow : function( ev ){
  1039. //if( !ev.currentTarget ){ return true; }
  1040. var rowIndex = $( ev.currentTarget ).index(),
  1041. fwd = $( '.unpaired-columns .forward-column .dataset' ).eq( rowIndex ).data( 'dataset' ),
  1042. rev = $( '.unpaired-columns .reverse-column .dataset' ).eq( rowIndex ).data( 'dataset' );
  1043. //this.debug( 'row:', rowIndex, fwd, rev );
  1044. this._pair( fwd, rev );
  1045. },
  1046. // ........................................................................ divider/partition
  1047. /** start dragging the visible divider/partition between unpaired and paired panes */
  1048. _startPartitionDrag : function( ev ){
  1049. var creator = this,
  1050. startingY = ev.pageY;
  1051. //this.debug( 'partition drag START:', ev );
  1052. $( 'body' ).css( 'cursor', 'ns-resize' );
  1053. creator.$( '.flexible-partition-drag' ).css( 'color', 'black' );
  1054. function endDrag( ev ){
  1055. //creator.debug( 'partition drag STOP:', ev );
  1056. // doing this by an added class didn't really work well - kept flashing still
  1057. creator.$( '.flexible-partition-drag' ).css( 'color', '' );
  1058. $( 'body' ).css( 'cursor', '' ).unbind( 'mousemove', trackMouse );
  1059. }
  1060. function trackMouse( ev ){
  1061. var offset = ev.pageY - startingY;
  1062. //creator.debug( 'partition:', startingY, offset );
  1063. if( !creator.adjPartition( offset ) ){
  1064. //creator.debug( 'mouseup triggered' );
  1065. $( 'body' ).trigger( 'mouseup' );
  1066. }
  1067. creator._adjUnpairedOnScrollbar();
  1068. startingY += offset;
  1069. }
  1070. $( 'body' ).mousemove( trackMouse );
  1071. $( 'body' ).one( 'mouseup', endDrag );
  1072. },
  1073. /** adjust the parition up/down +/-adj pixels */
  1074. adjPartition : function( adj ){
  1075. var $unpaired = this.$( '.unpaired-columns' ),
  1076. $paired = this.$( '.paired-columns' ),
  1077. unpairedHi = parseInt( $unpaired.css( 'height' ), 10 ),
  1078. pairedHi = parseInt( $paired.css( 'height' ), 10 );
  1079. //this.debug( adj, 'hi\'s:', unpairedHi, pairedHi, unpairedHi + adj, pairedHi - adj );
  1080. unpairedHi = Math.max( 10, unpairedHi + adj );
  1081. pairedHi = pairedHi - adj;
  1082. var movingUpwards = adj < 0;
  1083. // when the divider gets close to the top - lock into hiding the unpaired section
  1084. if( movingUpwards ){
  1085. if( this.unpairedPanelHidden ){
  1086. return false;
  1087. } else if( unpairedHi <= 10 ){
  1088. this.hideUnpaired();
  1089. return false;
  1090. }
  1091. } else {
  1092. if( this.unpairedPanelHidden ){
  1093. $unpaired.show();
  1094. this.unpairedPanelHidden = false;
  1095. }
  1096. }
  1097. // when the divider gets close to the bottom - lock into hiding the paired section
  1098. if( !movingUpwards ){
  1099. if( this.pairedPanelHidden ){
  1100. return false;
  1101. } else if( pairedHi <= 15 ){
  1102. this.hidePaired();
  1103. return false;
  1104. }
  1105. } else {
  1106. if( this.pairedPanelHidden ){
  1107. $paired.show();

Large files files are truncated, but you can click here to view the full file