1. 1 : /**
  2. 2 : * The Topics tool provides a rudimentary way of generating term clusters from a document or corpus and then seeing how each topic (term cluster) is distributed across the document or corpus.
  3. 3 : *
  4. 4 : * @example
  5. 5 : *
  6. 6 : * let config = {
  7. 7 : * "iterations": null,
  8. 8 : * "perDocLimit": null,
  9. 9 : * "seed": null,
  10. 10 : * "stopList": null,
  11. 11 : * "termsPerTopic": null,
  12. 12 : * "topics": null
  13. 13 : * };
  14. 14 : *
  15. 15 : * loadCorpus("austen").tool("Topics", config);
  16. 16 : *
  17. 17 : * @class Topics
  18. 18 : * @tutorial topics
  19. 19 : * @memberof Tools
  20. 20 : */
  21. 21 : Ext.define('Voyant.panel.Topics', {
  22. 22 : extend: 'Ext.panel.Panel',
  23. 23 : mixins: ['Voyant.panel.Panel'],
  24. 24 : alias: 'widget.topics',
  25. 25 : statics: {
  26. 26 : i18n: {
  27. 27 : topics: 'Topics',
  28. 28 : documents: 'Documents',
  29. 29 : topicWeight: 'Topic weight'
  30. 30 : },
  31. 31 : api: {
  32. 32 : /**
  33. 33 : * @memberof Tools.Topics
  34. 34 : * @instance
  35. 35 : * @property {stopList}
  36. 36 : * @default
  37. 37 : */
  38. 38 : stopList: 'auto',
  39. 39 :
  40. 40 : /**
  41. 41 : * @memberof Tools.Topics
  42. 42 : * @instance
  43. 43 : * @property {Number} topics The number of topics.
  44. 44 : * @default
  45. 45 : */
  46. 46 : topics: 10,
  47. 47 :
  48. 48 : /**
  49. 49 : * @memberof Tools.Topics
  50. 50 : * @instance
  51. 51 : * @property {Number} termsPerTopic The number of terms per topic.
  52. 52 : * @default
  53. 53 : */
  54. 54 : termsPerTopic: 10,
  55. 55 :
  56. 56 : /**
  57. 57 : * @memberof Tools.Topics
  58. 58 : * @instance
  59. 59 : * @property {Number} iterations The number of iterations.
  60. 60 : * @default
  61. 61 : */
  62. 62 : iterations: 100,
  63. 63 :
  64. 64 : /**
  65. 65 : * @memberof Tools.Topics
  66. 66 : * @instance
  67. 67 : * @property {Number} perDocLimit The number of terms to limit each document to.
  68. 68 : * @default
  69. 69 : */
  70. 70 : perDocLimit: 1000,
  71. 71 :
  72. 72 : /**
  73. 73 : * @memberof Tools.Topics
  74. 74 : * @instance
  75. 75 : * @property {Number} seed The seed to use for random number generation.
  76. 76 : * @default
  77. 77 : */
  78. 78 : seed: 0
  79. 79 : },
  80. 80 : glyph: 'xf1ea@FontAwesome'
  81. 81 : },
  82. 82 : config: {
  83. 83 : /**
  84. 84 : * @private
  85. 85 : */
  86. 86 : options: [{xtype: 'stoplistoption'},{
  87. 87 : xtype: 'numberfield',
  88. 88 : name: 'perDocLimit',
  89. 89 : fieldLabel: 'maximum words per document',
  90. 90 : labelAlign: 'right',
  91. 91 : value: 1000,
  92. 92 : minValue: 1,
  93. 93 : step: 100,
  94. 94 : listeners: {
  95. 95 : afterrender: function(field) {
  96. 96 : var win = field.up("window");
  97. 97 : if (win && win.panel) {
  98. 98 : field.setValue(parseInt(win.panel.getApiParam('perDocLimit')))
  99. 99 : field.setFieldLabel(win.panel.localize("perDocLimit"))
  100. 100 : }
  101. 101 : },
  102. 102 : change: function(field, val) {
  103. 103 : var win = field.up("window");
  104. 104 : if (val>5000 && win && win.panel) {
  105. 105 : win.panel.toastInfo({
  106. 106 : html: win.panel.localize("perDocLimitHigh"),
  107. 107 : anchor: win.getTargetEl(),
  108. 108 : align: 'tr',
  109. 109 : maxWidth: 400
  110. 110 : })
  111. 111 : }
  112. 112 : }
  113. 113 : }
  114. 114 : },{
  115. 115 : xtype: 'numberfield',
  116. 116 : name: 'iterations',
  117. 117 : fieldLabel: 'iterations per run',
  118. 118 : labelAlign: 'right',
  119. 119 : value: 100,
  120. 120 : minValue: 50,
  121. 121 : maxValue: 1000,
  122. 122 : step: 50,
  123. 123 : listeners: {
  124. 124 : afterrender: function(field) {
  125. 125 : var win = field.up("window");
  126. 126 : if (win && win.panel) {
  127. 127 : field.setValue(parseInt(win.panel.getApiParam('iterations')))
  128. 128 : field.setFieldLabel(win.panel.localize("iterations"))
  129. 129 : }
  130. 130 : }
  131. 131 : }
  132. 132 : },{
  133. 133 : xtype: 'textfield',
  134. 134 : name: 'seed',
  135. 135 : fieldLabel: 'Random Seed',
  136. 136 : labelAlign: 'right',
  137. 137 : value: 0
  138. 138 : }],
  139. 139 :
  140. 140 : currentTopics: [],
  141. 141 : currentDocument: undefined,
  142. 142 :
  143. 143 : corpus: undefined
  144. 144 : },
  145. 145 :
  146. 146 : constructor: function(config) {
  147. 147 : var me = this;
  148. 148 : Ext.apply(this, {
  149. 149 : title: this.localize('title'),
  150. 150 : layout: {
  151. 151 : type: 'hbox',
  152. 152 : pack: 'start',
  153. 153 : align: 'begin',
  154. 154 : padding: '10px'
  155. 155 : },
  156. 156 : defaultType: 'dataview',
  157. 157 : items: [{
  158. 158 : itemId: 'topicsView',
  159. 159 : flex: 2,
  160. 160 : padding: '0 5px 0 0',
  161. 161 : margin: '0 5px 0 0',
  162. 162 : height: '100%',
  163. 163 : scrollable: 'y',
  164. 164 : store: Ext.create('Ext.data.ArrayStore',{
  165. 165 : fields: ['index', 'terms', 'weight', 'diagnostics']
  166. 166 : }),
  167. 167 : selectionModel: {
  168. 168 : type: 'dataviewmodel',
  169. 169 : mode: 'MULTI'
  170. 170 : },
  171. 171 : itemSelector: 'div.topicItem',
  172. 172 : tpl: new Ext.XTemplate(
  173. 173 : '<div style="font-weight: bold">{[this.localize("topics")]}</div><tpl for=".">',
  174. 174 : '<div class="topicItem" style="background-color: {[this.getColor(values.index)]}">',
  175. 175 : '<div class="data weight" data-qtip="{[this.localize("topicWeight")]}">{[fm.number(values.weight*100, "00.0")]}%</div>',
  176. 176 : '<span class="term">{[values.terms.join("</span> <span class=\\"term\\">")]}</span>',
  177. 177 : '<div class="data diagnostics">{[this.processDiagnostics(values.diagnostics)]}</div>',
  178. 178 : '</div>',
  179. 179 : '</tpl>',
  180. 180 : {
  181. 181 : getColor: function(index) {
  182. 182 : var rgb = me.getColorForTopic(index);
  183. 183 : return 'rgba('+rgb.join(',')+',.33);'
  184. 184 : },
  185. 185 : localize: function(key) {
  186. 186 : return me.localize(key);
  187. 187 : },
  188. 188 : processDiagnostics: function(obj) {
  189. 189 : var string = '';
  190. 190 : for (var key in obj) {
  191. 191 : string += '<div><div class="key">'+key+'</div><div class="value">'+obj[key]+'</div></div>';
  192. 192 : }
  193. 193 : return string;
  194. 194 : }
  195. 195 : }
  196. 196 : ),
  197. 197 : listeners: {
  198. 198 : selectionchange: function(sel, selected) {
  199. 199 : sel.view.removeCls('showWeight');
  200. 200 : me.setCurrentDocument(undefined);
  201. 201 : me.setCurrentTopics(selected.map(function(item) { return item.get('index') }));
  202. 202 :
  203. 203 : me.down('#docsView').getSelectionModel().deselectAll(true);
  204. 204 : me.down('#docsView').refresh();
  205. 205 : }
  206. 206 : }
  207. 207 : },{
  208. 208 : itemId: 'docsView',
  209. 209 : flex: 1,
  210. 210 : height: '100%',
  211. 211 : scrollable: 'y',
  212. 212 : store: Ext.create('Ext.data.JsonStore',{
  213. 213 : fields: ['docId', 'weights']
  214. 214 : }),
  215. 215 : selectionModel: {
  216. 216 : type: 'dataviewmodel',
  217. 217 : mode: 'SINGLE',
  218. 218 : allowDeselect: true,
  219. 219 : toggleOnClick: true
  220. 220 : },
  221. 221 : itemSelector: 'div.topicItem',
  222. 222 : tpl: new Ext.XTemplate(
  223. 223 : '<div style="font-weight: bold">{[this.localize("documents")]}</div><tpl for=".">',
  224. 224 : '<div class="topicItem">',
  225. 225 : '{[this.getDocTitle(values.docId)]}',
  226. 226 : '<div class="chart">{[this.getChart(values.docId, values.weights)]}</div>',
  227. 227 : '</div>',
  228. 228 : '</tpl>',
  229. 229 : {
  230. 230 : getDocTitle: function(docId) {
  231. 231 : return me.getCorpus().getDocument(docId).getTitle();
  232. 232 : },
  233. 233 : getChart: function(docId, weights) {
  234. 234 : var chart = '';
  235. 235 : var topicStore = me.down('#topicsView').getStore();
  236. 236 : topicStore.each(function(item) {
  237. 237 : var index = item.get('index');
  238. 238 : var weight = weights[index];
  239. 239 : var rgb = me.getColorForTopic(index);
  240. 240 : var alpha = me.getCurrentDocument() === docId ? '1' : me.getCurrentTopics().length === 0 ? '.33' : me.getCurrentTopics().indexOf(index) !== -1 ? '1' : '.15';
  241. 241 : var color = 'rgba('+rgb.join(',')+','+alpha+')';
  242. 242 : chart += '<div style="width: '+(weight*100)+'%; background-color: '+color+'"> </div>';
  243. 243 : });
  244. 244 : return chart;
  245. 245 : },
  246. 246 : localize: function(key) {
  247. 247 : return me.localize(key);
  248. 248 : }
  249. 249 : }
  250. 250 : ),
  251. 251 : listeners: {
  252. 252 : selectionchange: function(sel, selected) {
  253. 253 : me.setCurrentTopics([]);
  254. 254 :
  255. 255 : var docId = selected[0] ? selected[0].get('docId') : undefined;
  256. 256 : me.setCurrentDocument(docId);
  257. 257 :
  258. 258 : var topicStore = me.down('#topicsView').getStore();
  259. 259 : if (docId) {
  260. 260 : me.down('#topicsView').addCls('showWeight').getSelectionModel().deselectAll(true);
  261. 261 : topicStore.beginUpdate();
  262. 262 : sel.view.getStore().query('docId', docId).each(function(item) {
  263. 263 : var weights = item.get('weights');
  264. 264 : weights.forEach(function(weight, index) {
  265. 265 : topicStore.findRecord('index', index).set('weight', weight);
  266. 266 : });
  267. 267 : });
  268. 268 : topicStore.endUpdate();
  269. 269 : topicStore.sort('weight', 'DESC');
  270. 270 : } else {
  271. 271 : me.down('#topicsView').removeCls('showWeight').getSelectionModel().deselectAll(true);
  272. 272 : topicStore.sort('index', 'ASC');
  273. 273 : }
  274. 274 :
  275. 275 : sel.view.refresh();
  276. 276 : }
  277. 277 : }
  278. 278 :
  279. 279 : }],
  280. 280 : dockedItems: {
  281. 281 : dock: 'bottom',
  282. 282 : xtype: 'toolbar',
  283. 283 : overflowHandler: 'scroller',
  284. 284 : items:[
  285. 285 : '<span class="info-tip" data-qtip="'+this.localize('searchTip')+'">'+this.localize('search')+'</span>'
  286. 286 : ,{
  287. 287 : xtype: 'textfield',
  288. 288 : name: 'searchField',
  289. 289 : hideLabel: true,
  290. 290 : width: 80,
  291. 291 : listeners: {
  292. 292 : change: {
  293. 293 : fn: me.onQuery,
  294. 294 : scope: me,
  295. 295 : buffer: 500
  296. 296 : }
  297. 297 : }
  298. 298 : },
  299. 299 : '<span class="info-tip" data-qtip="'+this.localize('limitTermsTip')+'">'+this.localize('limitTerms')+'</span>'
  300. 300 : ,{
  301. 301 : width: 60,
  302. 302 : hideLabel: true,
  303. 303 : xtype: 'numberfield',
  304. 304 : minValue: 1,
  305. 305 : maxValue: 100,
  306. 306 : listeners: {
  307. 307 : afterrender: function(slider) {
  308. 308 : slider.setValue(parseInt(this.getApiParam("termsPerTopic")))
  309. 309 : },
  310. 310 : change: function(slider, newvalue) {
  311. 311 : this.setApiParams({termsPerTopic: newvalue});
  312. 312 : },
  313. 313 : scope: this
  314. 314 : }
  315. 315 : },
  316. 316 : '<span class="info-tip" data-qtip="'+this.localize('numTopicsTip')+'">'+this.localize('numTopics')+'</span>'
  317. 317 : ,{
  318. 318 : width: 60,
  319. 319 : hideLabel: true,
  320. 320 : xtype: 'numberfield',
  321. 321 : minValue: 1,
  322. 322 : maxValue: 100,
  323. 323 : listeners: {
  324. 324 : afterrender: function(slider) {
  325. 325 : slider.setValue(parseInt(this.getApiParam("topics")))
  326. 326 : },
  327. 327 : change: function(slider, newvalue) {
  328. 328 : this.setApiParams({topics: newvalue});
  329. 329 : },
  330. 330 : scope: this
  331. 331 : }
  332. 332 : },{
  333. 333 : text: 'Run',//new Ext.Template(this.localize('runIterations')).apply([100]),
  334. 334 : itemId: 'iterations',
  335. 335 : glyph: 'xf04b@FontAwesome',
  336. 336 : tooltip: this.localize('runIterationsTip'),
  337. 337 : handler: this.runIterations,
  338. 338 : scope: this
  339. 339 : },{
  340. 340 : text: 'Toggle diagnostics',
  341. 341 : itemId: 'diagnostics',
  342. 342 : glyph: 'xf129@FontAwesome',
  343. 343 : handler: function(btn) {
  344. 344 : me.down('#topicsView').toggleCls('showDiagnostics');
  345. 345 : }
  346. 346 : }]
  347. 347 : }
  348. 348 : });
  349. 349 :
  350. 350 : this.callParent(arguments);
  351. 351 :
  352. 352 :
  353. 353 : this.mixins['Voyant.panel.Panel'].constructor.apply(this, arguments);
  354. 354 :
  355. 355 : // create a listener for corpus loading (defined here, in case we need to load it next)
  356. 356 : this.on('loadedCorpus', function(src, corpus) {
  357. 357 : this.setCorpus(corpus);
  358. 358 : if (this.rendered) {
  359. 359 : this.initialize();
  360. 360 : }
  361. 361 : else {
  362. 362 : this.on("afterrender", function() {
  363. 363 : this.initialize();
  364. 364 : }, this)
  365. 365 : }
  366. 366 :
  367. 367 : });
  368. 368 : },
  369. 369 :
  370. 370 : runIterations: function() {
  371. 371 : var params = this.getApiParams();
  372. 372 : params.tool = 'analysis.TopicModeling';
  373. 373 : params.corpus = this.getCorpus().getAliasOrId();
  374. 374 : params.noCache = 1;
  375. 375 :
  376. 376 : var iterations = this.getApiParam('iterations');
  377. 377 : var msg = Ext.MessageBox.progress({
  378. 378 : title: this.localize("runningIterations"),
  379. 379 : message: new Ext.Template(this.localize('runningIterationsCount')).apply([iterations])
  380. 380 : });
  381. 381 :
  382. 382 : Ext.Ajax.request({
  383. 383 : url: this.getTromboneUrl(),
  384. 384 : params: params,
  385. 385 : success: function(response, req) {
  386. 386 : msg.close();
  387. 387 :
  388. 388 : var data = JSON.parse(response.responseText);
  389. 389 :
  390. 390 : var topicsStore = this.down('#topicsView').getStore();
  391. 391 : topicsStore.loadData(data.topicModeling.topics.map(function(topic, i) {
  392. 392 : var words = topic.words.map(function(w) {
  393. 393 : return w.word;
  394. 394 : });
  395. 395 : var diagnostics = Object.assign({}, topic);
  396. 396 : delete diagnostics.words;
  397. 397 : return [i, words, 0, diagnostics];
  398. 398 : }));
  399. 399 :
  400. 400 : data.topicModeling.topicDocuments.sort(function(a, b) {
  401. 401 : var docIndexA = this.getCorpus().getDocument(a.docId).getIndex();
  402. 402 : var docIndexB = this.getCorpus().getDocument(b.docId).getIndex();
  403. 403 : return docIndexA-docIndexB;
  404. 404 : }.bind(this));
  405. 405 : this.down('#docsView').getStore().loadData(data.topicModeling.topicDocuments);
  406. 406 : this.down('#docsView').refresh();
  407. 407 : },
  408. 408 : scope: this
  409. 409 : });
  410. 410 : },
  411. 411 :
  412. 412 : getColorForTopic: function(topicIndex) {
  413. 413 : return this.getApplication().getColor(topicIndex);
  414. 414 : },
  415. 415 :
  416. 416 : onQuery: function(cmp, query) {
  417. 417 : var topicsView = this.down('#topicsView');
  418. 418 : topicsView.getEl().query('.highlighted').forEach(function(hi) {
  419. 419 : hi.classList.remove('highlighted');
  420. 420 : });
  421. 421 :
  422. 422 : if (query.trim() !== '') {
  423. 423 : var matcher = new RegExp(query, 'gi');
  424. 424 : var topicsStore = topicsView.getStore();
  425. 425 : var indexes = [];
  426. 426 : var matches = [];
  427. 427 : topicsStore.each(function(record) {
  428. 428 : var terms = record.get('terms');
  429. 429 : var termMatches = [];
  430. 430 : for (var i = 0; i < terms.length; i++) {
  431. 431 : var term = terms[i];
  432. 432 : if (term.search(matcher) !== -1) {
  433. 433 : termMatches.push(i);
  434. 434 : }
  435. 435 : }
  436. 436 : if (termMatches.length > 0) {
  437. 437 : indexes.push(record.get('index'));
  438. 438 : }
  439. 439 : matches.push(termMatches);
  440. 440 : });
  441. 441 : if (indexes.length > 0) {
  442. 442 : topicsView.setSelection(indexes.map(function(index) {return topicsStore.findRecord('index', index)}));
  443. 443 : topicsView.getNodes().forEach(function(node, i) {
  444. 444 : var nodeMatches = matches[i];
  445. 445 : if (nodeMatches.length > 0) {
  446. 446 : var terms = node.querySelectorAll('.term');
  447. 447 : nodeMatches.forEach(function(termIndex) {
  448. 448 : terms[termIndex].classList.add('highlighted');
  449. 449 : })
  450. 450 : }
  451. 451 : });
  452. 452 : } else {
  453. 453 : this.setCurrentTopics([]);
  454. 454 : topicsView.getSelectionModel().deselectAll(true);
  455. 455 : this.down('#docsView').refresh();
  456. 456 : }
  457. 457 : } else {
  458. 458 : this.setCurrentTopics([]);
  459. 459 : topicsView.getSelectionModel().deselectAll(true);
  460. 460 : this.down('#docsView').refresh();
  461. 461 : }
  462. 462 : },
  463. 463 :
  464. 464 : initialize: function() {
  465. 465 : this.runIterations();
  466. 466 : },
  467. 467 :
  468. 468 : getExtraDataExportItems: function() {
  469. 469 : return [{
  470. 470 : name: 'export',
  471. 471 : inputValue: 'dataAsTsv',
  472. 472 : boxLabel: this.localize('exportGridCurrentTsv')
  473. 473 : }]
  474. 474 : },
  475. 475 :
  476. 476 : exportDataAsTsv: function(panel, form) {
  477. 477 : var topicsValue = "Topic\t";
  478. 478 : var docsValue = 'Document Title';
  479. 479 :
  480. 480 : var topicOrder = [];
  481. 481 :
  482. 482 : var includeDiagnostics = this.down('#topicsView').hasCls('showDiagnostics');
  483. 483 :
  484. 484 : this.down('#topicsView').getStore().getData().each(function(record, i) {
  485. 485 : if (i === 0) {
  486. 486 : topicsValue += record.get('terms').map(function(t, i) { return 'Term '+i; }).join("\t");
  487. 487 : if (includeDiagnostics) {
  488. 488 : topicsValue += "\t"+Object.keys(record.get('diagnostics')).join("\t");
  489. 489 : }
  490. 490 : }
  491. 491 :
  492. 492 : topicOrder.push(record.get('index'));
  493. 493 :
  494. 494 : topicsValue += "\nTopic "+record.get('index')+"\t"+record.get('terms').join("\t");
  495. 495 : if (includeDiagnostics) {
  496. 496 : topicsValue += "\t"+Object.values(record.get('diagnostics')).join("\t");
  497. 497 : }
  498. 498 : docsValue += "\tTopic "+record.get('index')+' Weight';
  499. 499 : });
  500. 500 :
  501. 501 : this.down('#docsView').getStore().getData().each(function(record) {
  502. 502 : var title = this.getCorpus().getDocument(record.get('docId')).getTitle();
  503. 503 :
  504. 504 : var weights = topicOrder.map(function(topicIndex) {
  505. 505 : var weight = record.get('weights')[topicIndex];
  506. 506 : return Ext.util.Format.number(weight*100, "00.######");
  507. 507 : }).join("\t");
  508. 508 :
  509. 509 : docsValue += "\n"+title+"\t"+weights;
  510. 510 : }, this);
  511. 511 :
  512. 512 : Ext.create('Ext.window.Window', {
  513. 513 : title: panel.localize('exportDataTitle'),
  514. 514 : height: 290,
  515. 515 : width: 450,
  516. 516 : bodyPadding: 10,
  517. 517 : layout: {
  518. 518 : type: 'vbox',
  519. 519 : pack: 'start',
  520. 520 : align: 'stretch'
  521. 521 : },
  522. 522 : modal: true,
  523. 523 : defaults: {
  524. 524 : margin: '0 0 5px 0'
  525. 525 : },
  526. 526 : items: [{
  527. 527 : html: panel.localize('exportDataTsvMessage')
  528. 528 : },{
  529. 529 : html: '<textarea class="x-form-text-default x-form-textarea" style="height: 76px; width: 100%">'+topicsValue+'</textarea>'
  530. 530 : },{
  531. 531 : html: '<textarea class="x-form-text-default x-form-textarea" style="height: 76px; width: 100%">'+docsValue+'</textarea>'
  532. 532 : }],
  533. 533 : buttonAlign: 'center',
  534. 534 : buttons: [{
  535. 535 : text: 'OK',
  536. 536 : handler: function(btn) {
  537. 537 : btn.up('window').close();
  538. 538 : }
  539. 539 : }]
  540. 540 : }).show();
  541. 541 : }
  542. 542 :
  543. 543 : });