1. 1 : /**
  2. 2 : * StreamGraph is a visualization that depicts the change of the frequency of words in a corpus (or within a single document).
  3. 3 : *
  4. 4 : * @example
  5. 5 : *
  6. 6 : * let config = {
  7. 7 : * "bins": null,
  8. 8 : * "docId": null,
  9. 9 : * "docIndex": null,
  10. 10 : * "limit": null,
  11. 11 : * "query": null,
  12. 12 : * "stopList": null,
  13. 13 : * "withDistributions": null
  14. 14 : * };
  15. 15 : *
  16. 16 : * loadCorpus("austen").tool("streamgraph", config);
  17. 17 : *
  18. 18 : * @class StreamGraph
  19. 19 : * @tutorial streamgraph
  20. 20 : * @memberof Tools
  21. 21 : */
  22. 22 : Ext.define('Voyant.panel.StreamGraph', {
  23. 23 : extend: 'Ext.panel.Panel',
  24. 24 : mixins: ['Voyant.panel.Panel'],
  25. 25 : alias: 'widget.streamgraph',
  26. 26 : statics: {
  27. 27 : i18n: {
  28. 28 : },
  29. 29 : api: {
  30. 30 : /**
  31. 31 : * @memberof Tools.StreamGraph
  32. 32 : * @instance
  33. 33 : * @property {limit}
  34. 34 : * @default
  35. 35 : */
  36. 36 : limit: 5,
  37. 37 :
  38. 38 : /**
  39. 39 : * @memberof Tools.StreamGraph
  40. 40 : * @instance
  41. 41 : * @property {stopList}
  42. 42 : * @default
  43. 43 : */
  44. 44 : stopList: 'auto',
  45. 45 :
  46. 46 : /**
  47. 47 : * @memberof Tools.StreamGraph
  48. 48 : * @instance
  49. 49 : * @property {query}
  50. 50 : */
  51. 51 : query: undefined,
  52. 52 :
  53. 53 : /**
  54. 54 : * @memberof Tools.StreamGraph
  55. 55 : * @instance
  56. 56 : * @property {withDistributions}
  57. 57 : * @default
  58. 58 : */
  59. 59 : withDistributions: 'relative',
  60. 60 :
  61. 61 : /**
  62. 62 : * @memberof Tools.StreamGraph
  63. 63 : * @instance
  64. 64 : * @property {bins}
  65. 65 : * @default
  66. 66 : */
  67. 67 : bins: 50,
  68. 68 :
  69. 69 : /**
  70. 70 : * @memberof Tools.StreamGraph
  71. 71 : * @instance
  72. 72 : * @property {docIndex}
  73. 73 : */
  74. 74 : docIndex: undefined,
  75. 75 :
  76. 76 : /**
  77. 77 : * @memberof Tools.StreamGraph
  78. 78 : * @instance
  79. 79 : * @property {docId}
  80. 80 : */
  81. 81 : docId: undefined
  82. 82 : },
  83. 83 : glyph: 'xf1fe@FontAwesome'
  84. 84 : },
  85. 85 :
  86. 86 : config: {
  87. 87 : visLayout: undefined,
  88. 88 : vis: undefined,
  89. 89 : mode: 'corpus',
  90. 90 :
  91. 91 : layerData: undefined,
  92. 92 :
  93. 93 : graphId: undefined,
  94. 94 : options: [{xtype: 'stoplistoption'},{xtype: 'categoriesoption'}]
  95. 95 : },
  96. 96 :
  97. 97 : graphMargin: {top: 20, right: 60, bottom: 110, left: 80},
  98. 98 :
  99. 99 : MODE_CORPUS: 'corpus',
  100. 100 : MODE_DOCUMENT: 'document',
  101. 101 :
  102. 102 : constructor: function(config) {
  103. 103 : this.callParent(arguments);
  104. 104 : this.mixins['Voyant.panel.Panel'].constructor.apply(this, arguments);
  105. 105 :
  106. 106 : this.setGraphId(Ext.id(null, 'streamgraph_'));
  107. 107 : },
  108. 108 :
  109. 109 : initComponent: function() {
  110. 110 : var me = this;
  111. 111 :
  112. 112 : Ext.apply(me, {
  113. 113 : title: this.localize('title'),
  114. 114 : tbar: new Ext.Toolbar({
  115. 115 : overflowHandler: 'scroller',
  116. 116 : items: ['->',{
  117. 117 : xtype: 'legend',
  118. 118 : store: new Ext.data.JsonStore({
  119. 119 : fields: ['name', 'mark', 'active']
  120. 120 : }),
  121. 121 : listeners: {
  122. 122 : itemclick: function(view, record, el, index) {
  123. 123 : var isActive = Ext.fly(el.firstElementChild).hasCls('x-legend-inactive');
  124. 124 : record.set('active', isActive);
  125. 125 : var terms = this.getCurrentTerms();
  126. 126 : this.setApiParams({query: terms, limit: terms.length, stopList: undefined, categories: this.getApiParam("categories")});
  127. 127 : this.loadFromCorpus();
  128. 128 : },
  129. 129 : scope: this
  130. 130 : }
  131. 131 : },'->']
  132. 132 : }),
  133. 133 : bbar: {
  134. 134 : overflowHandler: 'scroller',
  135. 135 : items: [{
  136. 136 : xtype: 'querysearchfield'
  137. 137 : },{
  138. 138 : xtype: 'button',
  139. 139 : text: this.localize('clearTerms'),
  140. 140 : handler: function() {
  141. 141 : this.setApiParams({query: undefined});
  142. 142 : this.loadFromRecords([]);
  143. 143 : },
  144. 144 : scope: this
  145. 145 : },{
  146. 146 : xtype: 'corpusdocumentselector',
  147. 147 : singleSelect: true
  148. 148 : },{
  149. 149 : text: this.localize('freqsMode'),
  150. 150 : glyph: 'xf201@FontAwesome',
  151. 151 : tooltip: this.localize('freqsModeTip'),
  152. 152 : menu: {
  153. 153 : items: [{
  154. 154 : text: this.localize('relativeFrequencies'),
  155. 155 : checked: true,
  156. 156 : itemId: 'relative',
  157. 157 : group: 'freqsMode',
  158. 158 : checkHandler: function(item, checked) {
  159. 159 : if (checked) {
  160. 160 : this.setApiParam('withDistributions', 'relative');
  161. 161 : this.loadFromCorpus();
  162. 162 : }
  163. 163 : },
  164. 164 : scope: this
  165. 165 : }, {
  166. 166 : text: this.localize('rawFrequencies'),
  167. 167 : checked: false,
  168. 168 : itemId: 'raw',
  169. 169 : group: 'freqsMode',
  170. 170 : checkHandler: function(item, checked) {
  171. 171 : if (checked) {
  172. 172 : this.setApiParam('withDistributions', 'raw');
  173. 173 : this.loadFromCorpus();
  174. 174 : }
  175. 175 : },
  176. 176 : scope: this
  177. 177 : }]
  178. 178 : }
  179. 179 : },{
  180. 180 : xtype: 'slider',
  181. 181 : itemId: 'segmentsSlider',
  182. 182 : fieldLabel: this.localize('segments'),
  183. 183 : labelAlign: 'right',
  184. 184 : labelWidth: 70,
  185. 185 : width: 150,
  186. 186 : increment: 10,
  187. 187 : minValue: 10,
  188. 188 : maxValue: 300,
  189. 189 : listeners: {
  190. 190 : afterrender: function(slider) {
  191. 191 : slider.setValue(this.getApiParam('bins'));
  192. 192 : },
  193. 193 : changecomplete: function(slider, newvalue) {
  194. 194 : this.setApiParams({bins: newvalue});
  195. 195 : this.loadFromCorpus();
  196. 196 : },
  197. 197 : scope: this
  198. 198 : }
  199. 199 : }]
  200. 200 : }
  201. 201 : });
  202. 202 :
  203. 203 : this.on('loadedCorpus', function(src, corpus) {
  204. 204 : if (this.getCorpus().getDocumentsCount() == 1 && this.getMode() != this.MODE_DOCUMENT) {
  205. 205 : this.setMode(this.MODE_DOCUMENT);
  206. 206 : }
  207. 207 : if (!('bins' in this.getModifiedApiParams())) {
  208. 208 : if (this.getMode() == this.MODE_CORPUS) {
  209. 209 : var count = corpus.getDocumentsCount();
  210. 210 : var binsMax = 100;
  211. 211 : this.setApiParam('bins', count > binsMax ? binsMax : count);
  212. 212 : }
  213. 213 : }
  214. 214 : if (this.isVisible()) {
  215. 215 : this.loadFromCorpus();
  216. 216 : }
  217. 217 : }, this);
  218. 218 :
  219. 219 : this.on('corpusSelected', function(src, corpus) {
  220. 220 : if (src.isXType('corpusdocumentselector')) {
  221. 221 : this.setMode(this.MODE_CORPUS);
  222. 222 : this.setApiParams({docId: undefined, docIndex: undefined});
  223. 223 : this.setCorpus(corpus);
  224. 224 : this.loadFromCorpus();
  225. 225 : }
  226. 226 : });
  227. 227 :
  228. 228 : this.on('documentSelected', function(src, doc) {
  229. 229 : var docId = doc.getId();
  230. 230 : this.setApiParam('docId', docId);
  231. 231 : this.loadFromDocumentTerms();
  232. 232 : }, this);
  233. 233 :
  234. 234 : this.on('query', function(src, query) {
  235. 235 : var terms = this.getCurrentTerms();
  236. 236 : terms.push(query);
  237. 237 : this.setApiParams({query: terms, limit: terms.length, stopList: undefined});
  238. 238 : if (this.getMode() === this.MODE_DOCUMENT) {
  239. 239 : this.loadFromDocumentTerms();
  240. 240 : } else {
  241. 241 : this.loadFromCorpusTerms(this.getCorpus().getCorpusTerms());
  242. 242 : }
  243. 243 : }, this);
  244. 244 :
  245. 245 : this.on('resize', this.resizeGraph, this);
  246. 246 :
  247. 247 : this.on('boxready', this.initGraph, this);
  248. 248 :
  249. 249 : me.callParent(arguments);
  250. 250 : },
  251. 251 :
  252. 252 : loadFromCorpus: function() {
  253. 253 : var corpus = this.getCorpus();
  254. 254 : if (this.getApiParam('docId') || this.getApiParam('docIndex')) {
  255. 255 : this.loadFromDocumentTerms();
  256. 256 : } else if (corpus.getDocumentsCount() == 1) {
  257. 257 : this.loadFromDocument(corpus.getDocument(0));
  258. 258 : } else {
  259. 259 : this.loadFromCorpusTerms(corpus.getCorpusTerms());
  260. 260 : }
  261. 261 : },
  262. 262 :
  263. 263 : loadFromCorpusTerms: function(corpusTerms) {
  264. 264 : var params = this.getApiParams(['limit','stopList','query','withDistributions','bins','categories']);
  265. 265 : // ensure that we're not beyond the number of documents
  266. 266 : if (params.bins && params.bins > this.getCorpus().getDocumentsCount()) {
  267. 267 : params.bins = this.getCorpus().getDocumentsCount();
  268. 268 : }
  269. 269 : corpusTerms.load({
  270. 270 : callback: function(records, operation, success) {
  271. 271 : if (success) {
  272. 272 : this.setMode(this.MODE_CORPUS);
  273. 273 : this.loadFromRecords(records);
  274. 274 : } else {
  275. 275 : Voyant.application.showResponseError(this.localize('failedGetCorpusTerms'), operation);
  276. 276 : }
  277. 277 : },
  278. 278 : scope: this,
  279. 279 : params: params
  280. 280 : });
  281. 281 : },
  282. 282 :
  283. 283 : loadFromDocument: function(document) {
  284. 284 : if (document.then) {
  285. 285 : var me = this;
  286. 286 : document.then(function(document) {me.loadFromDocument(document);});
  287. 287 : } else {
  288. 288 : var ids = [];
  289. 289 : if (Ext.getClassName(document)=="Voyant.data.model.Document") {
  290. 290 : this.setApiParams({
  291. 291 : docIndex: undefined,
  292. 292 : query: undefined,
  293. 293 : docId: document.getId()
  294. 294 : });
  295. 295 : if (this.isVisible()) {
  296. 296 : this.loadFromDocumentTerms();
  297. 297 : }
  298. 298 : }
  299. 299 : }
  300. 300 : },
  301. 301 :
  302. 302 : loadFromDocumentTerms: function(documentTerms) {
  303. 303 : if (this.getCorpus()) {
  304. 304 : documentTerms = documentTerms || this.getCorpus().getDocumentTerms({autoLoad: false});
  305. 305 : documentTerms.load({
  306. 306 : callback: function(records, operation, success) {
  307. 307 : if (success) {
  308. 308 : this.setMode(this.MODE_DOCUMENT);
  309. 309 : this.loadFromRecords(records);
  310. 310 : }
  311. 311 : else {
  312. 312 : Voyant.application.showResponseError(this.localize('failedGetDocumentTerms'), operation);
  313. 313 : }
  314. 314 : },
  315. 315 : scope: this,
  316. 316 : params: this.getApiParams(['docId','docIndex','limit','stopList','query','withDistributions','bins','categories'])
  317. 317 : });
  318. 318 : }
  319. 319 : },
  320. 320 :
  321. 321 : loadFromRecords: function(records) {
  322. 322 : var legendData = [];
  323. 323 : var layers = [];
  324. 324 : records.forEach(function(record, index) {
  325. 325 : var key = record.getTerm();
  326. 326 : var values = record.get('distributions');
  327. 327 : for (var i = 0; i < values.length; i++) {
  328. 328 : if (layers[i] === undefined) {
  329. 329 : layers[i] = {};
  330. 330 : }
  331. 331 : layers[i][key] = values[i];
  332. 332 : }
  333. 333 : legendData.push({id: key, name: key, mark: this.getApplication().getColorForTerm(key, true), active: true});
  334. 334 : }, this);
  335. 335 :
  336. 336 : this.setLayerData(layers);
  337. 337 :
  338. 338 : this.down('[xtype=legend]').getStore().loadData(legendData);
  339. 339 :
  340. 340 : this.doLayout();
  341. 341 : },
  342. 342 :
  343. 343 : doLayout: function(layers) {
  344. 344 : var layers = this.getLayerData();
  345. 345 : if (layers !== undefined) {
  346. 346 : var me = this;
  347. 347 :
  348. 348 : var keys = [];
  349. 349 : this.down('[xtype=legend]').getStore().each(function(r) { keys.push(r.getId()); });
  350. 350 :
  351. 351 : var steps;
  352. 352 : if (this.getMode() === this.MODE_DOCUMENT) {
  353. 353 : steps = this.getApiParam('bins');
  354. 354 : } else {
  355. 355 : var bins = this.getApiParam('bins');
  356. 356 : var docsCount = this.getCorpus().getDocumentsCount();
  357. 357 :
  358. 358 : steps = bins < docsCount ? bins : docsCount;
  359. 359 : }
  360. 360 :
  361. 361 : this.getVisLayout().keys(keys);
  362. 362 : var processedLayers = this.getVisLayout()(layers);
  363. 363 :
  364. 364 : var width = this.body.down('svg').getWidth() - this.graphMargin.left - this.graphMargin.right;
  365. 365 : var x = d3.scaleLinear().domain([0, steps-1]).range([0, width]);
  366. 366 :
  367. 367 : var min = d3.min(processedLayers, function(layer) {
  368. 368 : return d3.min(layer, function(d) { return d[0]; });
  369. 369 : });
  370. 370 : var max = d3.max(processedLayers, function(layer) {
  371. 371 : return d3.max(layer, function(d) { return d[1]; });
  372. 372 : });
  373. 373 :
  374. 374 : var height = this.body.down('svg').getHeight() - this.graphMargin.top - this.graphMargin.bottom;
  375. 375 : var y = d3.scaleLinear().domain([min, max]).range([height, 0]);
  376. 376 :
  377. 377 : var area = d3.area()
  378. 378 : .x(function(d, i) { return x(i); })
  379. 379 : .y0(function(d) { return y(d[0]); })
  380. 380 : .y1(function(d) { return y(d[1]); })
  381. 381 : .curve(d3.curveCatmullRom);
  382. 382 :
  383. 383 : var xAxis;
  384. 384 : if (this.getMode() === this.MODE_CORPUS) {
  385. 385 : var xAxisDomain = [];
  386. 386 : this.getCorpus().getDocuments().each(function(doc) {
  387. 387 : xAxisDomain.push(doc.getTinyLabel());
  388. 388 : });
  389. 389 : var xAxisScale = d3.scalePoint().domain(xAxisDomain).range([0, width]);
  390. 390 : xAxis = d3.axisBottom(xAxisScale);
  391. 391 : } else {
  392. 392 : xAxis = d3.axisBottom(x);
  393. 393 : }
  394. 394 :
  395. 395 : var yAxis = d3.axisLeft(y);
  396. 396 :
  397. 397 : var paths = this.getVis().selectAll('path').data(processedLayers, function(d) { return d; });
  398. 398 :
  399. 399 : paths
  400. 400 : .attr('d', function(d) { return area(d); })
  401. 401 : .style('fill', function(d) { return me.getApplication().getColorForTerm(d.key, true); })
  402. 402 : .select('title').text(function (d) { return d.key; });
  403. 403 :
  404. 404 : paths.enter().append('path')
  405. 405 : .attr('d', function(d) { return area(d); })
  406. 406 : .style('fill', function(d) { return me.getApplication().getColorForTerm(d.key, true); })
  407. 407 : .append('title').text(function (d) { return d.key; });
  408. 408 :
  409. 409 : paths.exit().remove();
  410. 410 :
  411. 411 : this.getVis().selectAll('g.axis').remove();
  412. 412 :
  413. 413 : this.getVis().append('g')
  414. 414 : .attr('class', 'axis x')
  415. 415 : .attr('transform', 'translate(0,'+height+')')
  416. 416 : .call(xAxis);
  417. 417 :
  418. 418 : var xAxisText;
  419. 419 : if (this.getMode() === this.MODE_CORPUS) {
  420. 420 : this.getVis().select('g.axis.x').selectAll('text').each(function() {
  421. 421 : d3.select(this)
  422. 422 : .attr('text-anchor', 'end')
  423. 423 : .attr('transform', 'rotate(-45)');
  424. 424 : });
  425. 425 :
  426. 426 : xAxisText = this.localize('documents');
  427. 427 : } else {
  428. 428 : xAxisText = this.localize('documentSegments');
  429. 429 : }
  430. 430 : this.getVis().select('g.axis.x').append("text")
  431. 431 : .attr('text-anchor', 'middle')
  432. 432 : .attr('transform', 'translate('+width/2+', '+(this.graphMargin.bottom-30)+')')
  433. 433 : .attr('fill', '#000')
  434. 434 : .text(xAxisText);
  435. 435 :
  436. 436 : this.getVis().append('g')
  437. 437 : .attr('class', 'axis y')
  438. 438 : .attr('transform', 'translate(0,0)')
  439. 439 : .call(yAxis);
  440. 440 :
  441. 441 : var yAxisText;
  442. 442 : if (this.getApiParam('withDistributions') === 'raw') {
  443. 443 : yAxisText = this.localize('rawFrequencies');
  444. 444 : } else {
  445. 445 : yAxisText = this.localize('relativeFrequencies');
  446. 446 : }
  447. 447 : this.getVis().select('g.axis.y').append("text")
  448. 448 : .attr('text-anchor', 'middle')
  449. 449 : .attr('transform', 'translate(-'+(this.graphMargin.left-20)+', '+height/2+') rotate(-90)')
  450. 450 : .attr('fill', '#000')
  451. 451 : .text(yAxisText);
  452. 452 : }
  453. 453 : },
  454. 454 :
  455. 455 : getCurrentTerms: function() {
  456. 456 : var terms = [];
  457. 457 : this.down('[xtype=legend]').getStore().each(function(record) {
  458. 458 : if (record.get('active')) {
  459. 459 : terms.push(record.get('name'));
  460. 460 : }
  461. 461 : }, this);
  462. 462 : return terms;
  463. 463 : },
  464. 464 :
  465. 465 : initGraph: function() {
  466. 466 : if (this.getVisLayout() === undefined) {
  467. 467 : var el = this.getLayout().getRenderTarget();
  468. 468 :
  469. 469 : this.setVisLayout(d3.stack().offset(d3.stackOffsetWiggle).order(d3.stackOrderInsideOut));
  470. 470 : this.setVis(d3.select(el.dom).append('svg').attr('id',this.getGraphId()).append('g').attr('transform', 'translate('+this.graphMargin.left+','+this.graphMargin.top+')'));
  471. 471 :
  472. 472 : this.resizeGraph();
  473. 473 : }
  474. 474 : },
  475. 475 :
  476. 476 : resizeGraph: function() {
  477. 477 : var el = this.body;//getLayout().getRenderTarget();
  478. 478 : var width = el.getWidth();
  479. 479 : var height = el.getHeight();
  480. 480 :
  481. 481 : d3.select(el.dom).select('svg').attr('width', width).attr('height', height);
  482. 482 :
  483. 483 : this.doLayout();
  484. 484 : }
  485. 485 : });
  486. 486 :