/**
* Constellation produces a specialized network graph of high frequency terms that are connected by similar word vectors.
*
* @example
*
* let config = {
* "analysis": "ca",
* "dimensions": 2,
* "limit": 50
* };
*
* loadCorpus("austen").tool("constellation", config);
* @class Constellation
* @tutorial constellation
* @memberof Tools
*/
Ext.define('Voyant.panel.Constellation', {
extend: 'Ext.panel.Panel',
mixins: ['Voyant.panel.Panel'],
alias: 'widget.constellation',
statics: {
i18n: {
title: 'Constellation',
options: 'Options',
selectedTerms: 'Selected Terms',
hideUnselected: 'Hide Unselected Terms',
cutoff: 'Similarity Threshold',
numTerms: 'Terms',
analysis: 'Analysis',
ca: 'Correspondence Analysis',
pca: 'Principal Component Analysis',
tsne: 't-SNE',
distance: 'Distance Metric',
cosine: 'Cosine Similarity',
euclidean: 'Euclidean',
maxStrength: 'Max Strength',
maxConnections: 'Max Connections',
linkStrength: 'Link Strength',
bodyStrength: 'Body Strength',
help: 'Constellation produces a specialized network graph of high frequency terms that are connected by similar word vectors.',
helpTip: 'Constellation produces a specialized network graph of high frequency terms that are connected by similar word vectors.'
},
api: {
/**
* @memberof Tools.Constellation
* @instance
* @property {String} analysis The type of analysis to perform. Options are: 'ca', 'pca', and 'tsne'.
*/
analysis: 'ca',
/**
* @memberof Tools.Constellation
* @instance
* @property {docId}
*/
docId: undefined,
/**
* @memberof Tools.Constellation
* @instance
* @property {limit}
* @default
*/
limit: 150,
/**
* @memberof Tools.Constellation
* @instance
* @property {Number} dimensions The number of dimensions to render, either 2 or 3.
* @default
*/
dimensions: 3,
/**
* @memberof Tools.Constellation
* @instance
* @property {stopList}
* @default
*/
stopList: 'auto'
},
glyph: 'xf1e0@FontAwesome'
},
config: {
caStore: undefined,
pcaStore: undefined,
tsneStore: undefined,
jsLoaded: false,
corpusLoaded: false,
chartData: undefined,
metric: 'cosine',
maxStrength: 1000,
maxConnections: 50,
linkStrength: 1500,
bodyStrength: -1500,
options: [{xtype: 'stoplistoption'}]
},
constructor: function(config) {
this.callParent(arguments);
this.mixins['Voyant.panel.Panel'].constructor.apply(this, arguments);
},
initComponent: function() {
this.setCaStore(Ext.create('Voyant.data.store.CAAnalysis', {
parentPanel: this,
listeners: { load: this.handleData, scope: this }
}));
this.setPcaStore(Ext.create('Voyant.data.store.PCAAnalysis', {
parentPanel: this,
listeners: { load: this.handleData, scope: this }
}));
this.setTsneStore(Ext.create('Voyant.data.store.TSNEAnalysis', {
parentPanel: this,
listeners: { load: this.handleData, scope: this }
}));
Ext.apply(this, {
title: this.localize('title'),
layout: 'border',
items: [{
xtype: 'container',
region: 'center',
layout: 'fit',
style: 'backgroundColor: #fff',
items: {
xtype: 'component',
itemId: 'visParent',
listeners: {
resize: function(cmp, width, height) {
var svg = cmp.getEl().down('svg');
if (svg) {
d3.select(svg.dom).attr('width', width).attr('height', height);
Ext.Function.defer(this.zoomToFit, 50, this);
}
},
scope: this
}
}
},{
title: this.localize('options'),
xtype: 'panel',
width: 200,
minWidth: 150,
region: 'west',
split: true,
collapsible: true,
scrollable: 'y',
layout: {
type: 'vbox',
align: 'stretch'
},
defaults: {
xtype: 'button',
margin: '5',
labelAlign: 'top'
},
items: [{
xtype: 'documentselectorbutton'
},{
text: this.localize('analysis'),
itemId: 'analysis',
glyph: 'xf1ec@FontAwesome',
menu: {
items: [
{text: this.localize('ca'), itemId: 'analysis_ca', group:'analysis', xtype: 'menucheckitem'},
{text: this.localize('pca'), itemId: 'analysis_pca', group:'analysis', xtype: 'menucheckitem'},
{text: this.localize('tsne'), itemId: 'analysis_tsne', group:'analysis', xtype: 'menucheckitem'}
],
listeners: {
render: function(field) {
field.child('#analysis_'+this.getApiParam('analysis')).setChecked(true);
},
click: function(menu, item) {
if (item !== undefined) {
var analysis = item.getItemId().split('_')[1];
if (analysis !== this.getApiParam('analysis')) {
this.setApiParam('analysis', analysis);
this.loadFromApis();
}
}
},
scope: this
}
}
},{
xtype: 'slider',
fieldLabel: this.localize('numTerms'),
minValue: 10,
maxValue: 1000,
increment: 5,
listeners: {
render: function(field) {
field.setValue(this.getApiParam('limit'));
},
changecomplete: function(field, newVal) {
this.setApiParam('limit', newVal);
this.loadFromApis();
},
scope: this
}
},{
xtype: 'container',
html: '<hr style="border: none; border-top: 1px solid #cfcfcf;"/>'
},{
text: this.localize('distance'),
menu: {
items: [
{text: this.localize('cosine'), itemId: 'metric_cosine', group: 'metric', xtype: 'menucheckitem'},
{text: this.localize('euclidean'), itemId: 'metric_euclidean', group: 'metric', xtype: 'menucheckitem'}
],
listeners: {
render: function(field) {
field.child('#metric_'+this.getMetric()).setChecked(true);
},
click: function(menu, item) {
if (item !== undefined) {
var metric = item.getItemId().split('_')[1];
if (metric !== this.getMetric()) {
this.setMetric(metric);
this.loadFromApis();
}
}
},
scope: this
}
}
},{
xtype: 'slider',
fieldLabel: this.localize('maxStrength'),
minValue: 0,
maxValue: 8000,
increment: 1,
listeners: {
render: function(field) {
field.setValue(this.getMaxStrength());
},
changecomplete: function(field, newVal) {
this.setMaxStrength(newVal);
this.updateGraph();
},
scope: this
}
},{
xtype: 'slider',
fieldLabel: this.localize('maxConnections'),
minValue: 2,
maxValue: 100,
increment: 1,
listeners: {
render: function(field) {
field.setValue(this.getMaxConnections());
},
changecomplete: function(field, newVal) {
this.setMaxConnections(newVal);
this.updateGraph();
},
scope: this
}
},{
xtype: 'slider',
fieldLabel: this.localize('linkStrength'),
value: this.getLinkStrength(),
minValue: 1,
maxValue: 3000,
increment: 1,
listeners: {
change: function(field, newVal) {
this.setLinkStrength(newVal);
this.getChartData().simulation.force("link").distance(x => x.sim * newVal);
this.getChartData().simulation.alpha(1).restart();
},
scope: this
}
},{
xtype: 'slider',
fieldLabel: this.localize('bodyStrength'),
value: this.getBodyStrength(),
minValue: -3000,
maxValue: -1,
increment: 1,
listeners: {
change: function(field, newVal) {
this.setBodyStrength(newVal);
this.getChartData().simulation.force("body").strength(newVal);
this.getChartData().simulation.alpha(1).restart();
},
scope: this
}
},{
xtype: 'container',
html: '<hr style="border: none; border-top: 1px solid #cfcfcf;"/>'
},{
xtype: 'checkbox',
itemId: 'hideUnselected',
boxLabel: this.localize('hideUnselected'),
listeners: {
change: function(field, val) {
this.getChartData().hide_unselected = val;
Voyant.panel.Constellation.constellation.update_graph(this.getChartData());
},
scope: this
}
},{
xtype: 'multiselector',
title: this.localize('selectedTerms'),
itemId: 'selectedTerms',
fieldName: 'label',
fieldTitle: 'Term',
disableSelection: true,
search: {
xtype: 'multiselector-search',
width: 200,
height: 200,
store: Ext.create('Ext.data.JsonStore', {
fields: ['id', 'label'],
data: []
})
},
store: Ext.create('Ext.data.JsonStore', {
fields: ['id', 'label'],
data: [],
listeners: {
add: function(store, records) {
var record = records[0];
var node = this.parseSelection(record.get('label'));
this.getChartData().selection.add(node.id);
Voyant.panel.Constellation.constellation.update_graph(this.getChartData(), false);
},
remove: function(store, records) {
var record = records[0];
this.getChartData().selection.delete(record.get('id'));
Voyant.panel.Constellation.constellation.update_graph(this.getChartData(), false);
},
scope: this
}
})
}]
}]
});
this.on('boxready', function(src, corpus) {
console.log('boxready')
d3.select('head').insert('script')
.attr('type', 'module')
.attr('onload', function() {
this.setJsLoaded(true);
if (this.getCorpusLoaded()) {
console.log('br load')
this.loadFromApis();
}
}.bind(this))
.attr('src', Voyant.application.getBaseUrl()+'resources/constellation/main.js');
d3.select('head').insert('link')
.attr('rel', 'stylesheet')
.attr('type', 'text/css')
.attr('href', Voyant.application.getBaseUrl()+'resources/constellation/style.css');
}, this);
this.on('loadedCorpus', function(src, corpus) {
if (this.isVisible()) {
console.log('loadedCorpus')
this.getCaStore().setCorpus(corpus);
this.getPcaStore().setCorpus(corpus);
this.getTsneStore().setCorpus(corpus);
this.setCorpusLoaded(true);
if (this.getJsLoaded()) {
console.log('lc load')
this.loadFromApis();
}
}
}, this);
this.on('corpusSelected', function(src, corpus) {
this.setApiParam('docId', undefined);
this.loadFromApis();
}, this);
this.on('documentsSelected', function(src, docIds) {
this.setApiParam('docId', docIds);
this.loadFromApis();
}, this);
this.on('activate', function() { // load after tab activate (if we're in a tab panel)
if (this.getCorpus()) {
// only preloadEntities if there isn't already data
// if (this.down('voyantnetworkgraph').getNodeData().length === 0) {
// Ext.Function.defer(this.preloadEntities, 100, this);
// }
}
}, this);
this.on('query', function(src, query) {this.loadFromQuery(query);}, this);
this.on('constellationJSLoaded', function() {
console.log('constellationJSLoaded');
if (this.getJsLoaded() && this.getCorpusLoaded()) {
this.loadFromApis();
}
})
this.callParent(arguments);
},
initGraph: function(nodes, edges) {
var el = this.down('#visParent').getEl();
el.update('');
var width = el.getWidth();
var height = el.getHeight();
var graphId = 'constellationGraph';
var svg = d3.select(el.dom).append('svg').attr('width', width).attr('height', height).attr('id', graphId);
[svg, simulation] = Voyant.panel.Constellation.constellation.init_graph(width, height, graphId);
simulation.force("link").distance(x => x.sim * this.getLinkStrength());
simulation.force("body").strength(this.getBodyStrength());
svg.select("#nodes").on('click', function(event) {
var term = event.target.__data__.label;
this.down('#selectedTerms').getStore().loadRawData([event.target.__data__], true);
// this.dispatchEvent('termsClicked', this, [term]);
}.bind(this));
var metric = this.getMetric() === 'cosine' ? Voyant.panel.Constellation.vec.cosine_similarity : Voyant.panel.Constellation.vec.distance;
var selection = new Set();
this.down('#selectedTerms').getStore().each(function(r) {
selection.add(r.get('id'));
})
this.setChartData({
svg: svg,
simulation: simulation,
metric: metric,
cutoff: this.getMaxStrength(),
connections: this.getMaxConnections(),
hidden: false,//document.getElementById("hidetext").checked,
hide_unselected: this.down('#hideUnselected').getValue(),
selection: selection,
nodes: nodes,
edges: edges
});
},
updateGraph: function() {
var chartData = this.getChartData();
chartData.cutoff = this.getMaxStrength();
chartData.connections = this.getMaxConnections();
Voyant.panel.Constellation.constellation.update_graph(chartData);
},
handleNodeClick: function(event, data) {
event.stopImmediatePropagation();
event.preventDefault();
this.dispatchEvent('termsClicked', this, [data.id]);
},
loadFromApis: function() {
if (Voyant.panel.Constellation.vec === undefined) return;
var params = {};
Ext.apply(params, this.getApiParams());
delete params.analysis;
if (this.getApiParam('analysis') === 'tsne') {
this.getTsneStore().load({
params: params
});
} else if (this.getApiParam('analysis') === 'pca') {
this.getPcaStore().load({
params: params
});
} else {
this.getCaStore().load({
params: params
});
}
},
handleData: function(store) {
var rec = store.getAt(0);
var tokens = rec.getTokens();
var data = tokens.map(function(token) { return token.getData(); });
if (this.getApiParam('analysis') === 'ca') {
data = data.filter(function(d) { return d.category === 'term'; });
}
let nodes = data.map(x => {
return {
id: x["term"],
label: x["term"],
vector: x["vector"]
}
});
var selectedTermsToRemove = [];
var selectedTermsStore = this.down('#selectedTerms').getStore();
selectedTermsStore.each(function(record) {
var id = record.get('id');
if (nodes.find(n => n.id === id) === undefined) {
selectedTermsToRemove.push(record);
}
});
selectedTermsToRemove.forEach(function(r) {
selectedTermsStore.remove(r);
})
this.down('#selectedTerms').getSearch().store.loadRawData(nodes);
var metric = this.getMetric() === 'cosine' ? Voyant.panel.Constellation.vec.cosine_similarity : Voyant.panel.Constellation.vec.distance;
let edges = Voyant.panel.Constellation.constellation.generate_edges(nodes, metric);
this.initGraph(nodes, edges);
this.updateGraph();
},
parseSelection(selection) {
var chartData = this.getChartData();
var vec = Voyant.panel.Constellation.vec;
// Return early if the node already exists.
let current = vec.get_from_array(selection, chartData.nodes);
if (current !== false) {
return current
}
let values = selection.split(" ");
let temp = null;
let next = vec.add;
for (let x of values) {
let row = vec.get_from_array(x, chartData.nodes).vector
if (x === "+") {
next = vec.add
} else if (x === "-") {
next = vec.subtract
} else if (row !== undefined) {
temp = next(temp, row)
} else {
console.log("unknown word", x)
}
}
// Return early if selection is invalid.
if (temp === null) {
return false
}
return Voyant.panel.Constellation.constellation.insert_node(selection, selection, temp, chartData)
}
});