/**
* Constellations
*
* @class Constellations
* @memberof Tools
*/
Ext.define('Voyant.panel.Constellations', {
extend: 'Ext.panel.Panel',
mixins: ['Voyant.panel.Panel'],
alias: 'widget.constellations',
statics: {
i18n: {
title: 'Constellations',
cutoff: 'Similarity Threshold',
numTerms: 'Terms'
},
api: {
docId: undefined,
limit: 50,
dimensions: 3,
stopList: 'auto'
},
glyph: 'xf1e0@FontAwesome'
},
config: {
caStore: undefined,
allNodeData: undefined,
allEdgeData: undefined,
cutoff: 50,
vis: undefined,
simulation: undefined,
physics: {
nodeGravity: -500, // negative = repel, positive = attract
springLength: 50
},
zoom: undefined, // d3 zoom
zoomExtent: [0.25, 8],
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', {
listeners: { load: this.handleData, scope: this }
}));
Ext.apply(this, {
title: this.localize('title'),
dockedItems: [{
dock: 'bottom',
xtype: 'toolbar',
overflowHandler: 'scroller',
items: [{
xtype: 'corpusdocumentselector'
},{
xtype: 'slider',
fieldLabel: this.localize('numTerms'),
labelAlign: 'right',
labelWidth: 50,
width: 200,
minValue: 10,
maxValue: 250,
increment: 5,
listeners: {
render: function(field) {
field.setValue(this.getApiParam('limit'));
},
changecomplete: function(field, newVal) {
this.setApiParam('limit', newVal);
this.loadFromApis();
},
scope: this
}
},{
xtype: 'slider',
fieldLabel: this.localize('cutoff'),
labelAlign: 'right',
labelWidth: 150,
width: 300,
minValue: 0,
maxValue: 150,
increment: 1,
listeners: {
render: function(field) {
field.setValue(this.getCutoff());
},
changecomplete: function(field, newVal) {
this.setCutoff(newVal);
this.updateGraph();
},
scope: this
}
}]
}],
listeners: {
scope: this
}
});
this.on('boxready', function(src, corpus) {
// this.initGraph();
}, this);
this.on('loadedCorpus', function(src, corpus) {
if (this.isVisible()) {
this.getCaStore().setCorpus(corpus);
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.callParent(arguments);
},
initGraph: function(nodes, edges) {
var el = this.getLayout().getRenderTarget();
el.update('');
var width = el.getWidth();
var height = el.getHeight();
var svg = d3.select(el.dom).append('svg').attr('width', width).attr('height', height);
var group = svg.append('g');
group.append('g').attr('class', 'edges');
group.append('g').attr('class', 'nodes');
group.append('g').attr('class', 'labels');
this.setVis(group);
var zoom = d3.zoom()
.scaleExtent(this.getZoomExtent())
.on('zoom', function(event) {
group.attr('transform', event.transform);
});
this.setZoom(zoom);
svg.call(zoom);
var physics = this.getPhysics();
this.setSimulation(d3.forceSimulation(nodes)
.force('x', d3.forceX(width/2))
.force('y', d3.forceY(height/2))
.force('link', d3.forceLink(edges).id(function(d) { return d.id; }).distance(physics.springLength))
.force('charge', d3.forceManyBody().strength(physics.nodeGravity))
.force('center', d3.forceCenter(width/2, height/2))
.on('tick', function() {
svg.select('.edges').selectAll('line')
.attr('x1', function(d) { return d.source.x; })
.attr('y1', function(d) { return d.source.y; })
.attr('x2', function(d) { return d.target.x; })
.attr('y2', function(d) { return d.target.y; });
svg.select('.nodes').selectAll('circle')
.attr('cx', function(d) { return d.x})
.attr('cy', function(d) { return d.y});
svg.select('.labels').selectAll('text')
.attr('x', function(d) { return d.x +15 })
.attr('y', function(d) { return d.y +5 })
if (this.getSimulation().alpha() < 0.075) {
this.getSimulation().alpha(-1); // trigger end event
}
}.bind(this))
.on('end', function() {
Ext.Function.defer(this.zoomToFit, 100, this);
}.bind(this))
);
this.getSimulation().stop();
},
updateGraph: function() {
var cutoff = this.getCutoff() / 1000;
[nodes, edges] = this.doFilter(this.getAllNodeData(), this.getAllEdgeData(), cutoff, '');
this.getVis().select('.labels')
.selectAll('text')
.data(nodes)
.join('text')
.classed('hidden', false)
.classed('label', true)
.style('cursor', 'pointer')
.attr('font-size', '16px')
.attr('font-family', '"Palatino Linotype", "Book Antiqua", Palatino, serif')
.text(function(d) { return d.id })
.on('click', this.handleNodeClick.bind(this))
.call(d3.drag()
.on('start', this.handleDragStart.bind(this))
.on('drag', this.handleDrag.bind(this))
.on('end', this.handleDragEnd.bind(this))
)
this.getVis().select('.nodes')
.selectAll('circle')
.data(nodes, function(d) { return d.id })
.join('circle')
.classed('node', true)
.classed('selected', function(node) { return node.selected === true })
.style('cursor', 'pointer')
.style('stroke', '#6baed6')
.style('stroke-width', 1)
.style('fill', '#c6dbef')
.attr('r', function(node) {
if (node.selected === true) {
return 30;
}
return 10;
})
.on('click', this.handleNodeClick.bind(this))
.call(d3.drag()
.on('start', this.handleDragStart.bind(this))
.on('drag', this.handleDrag.bind(this))
.on('end', this.handleDragEnd.bind(this))
)
this.getVis().select('.edges')
.selectAll('line')
.data(edges)
.join('line')
.classed('edge', true)
.style('stroke', '#000')
.style('stroke-opacity', 0.1)
this.getSimulation()
.nodes(nodes)
.force('link')
.links(edges)
this.getSimulation().alpha(1).restart();
},
handleNodeClick: function(event, data) {
event.stopImmediatePropagation();
event.preventDefault();
this.dispatchEvent('termsClicked', this, [data.id]);
},
handleDragStart: function(event) {
this.getSimulation().alpha(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
},
handleDrag: function(event) {
this.getSimulation().alpha(0.3);
event.subject.fx = event.x;
event.subject.fy = event.y;
},
handleDragEnd: function(event) {
event.subject.fx = null;
event.subject.fy = null;
},
loadFromApis: function() {
var params = {};
Ext.apply(params, this.getApiParams());
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(); });
data = data.filter(function(d) { return d.category === 'term'; });
let all_node_data = data.map(x => { return {id: x["term"] }})
let all_edge_data = []
// Populate edge data with distances
for (let pairs of this.combinations(data)) {
let sim = this.distance(pairs[0]["vector"], pairs[1]["vector"]);
all_edge_data.push({
"source": pairs[0]["term"],
"target": pairs[1]["term"],
"sim": sim
})
}
this.setAllNodeData(all_node_data);
this.setAllEdgeData(all_edge_data);
this.initGraph(all_node_data, all_edge_data);
this.updateGraph();
},
doFilter: function(nodes, edges, cutoff, selection) {
if (nodes === undefined || edges === undefined) return [[],[]];
let nodes_temp = new Set()
// Filter out edges
let edges_filtered = edges.filter(edge => {
if (edge.sim <= cutoff) {
nodes_temp.add(edge.source.id)
nodes_temp.add(edge.target.id)
return true
}
})
// Filter out nodes
let nodes_filtered = nodes.filter(x => {
x.selected = false
if (x.id === selection) {
x.selected = true
return true
}
return nodes_temp.has(x["id"])
});
if (nodes_filtered.length === 0) {
console.warn('no nodes!')
}
console.log('num edges', edges_filtered.length);
return [nodes_filtered, edges_filtered];
},
distance: function(source, target) {
// This function returns the Euclidean distance between two arrays.
return Math.sqrt(source.reduce((sum, current, index) => {
const x = Math.pow(current - target[index], 2)
return sum + x
}, 0));
},
combinations: function*(items) {
// This function will return all pairs of values in an iterable.
for (let row of items.entries()) {
const index = row[0];
const x = row[1];
for (let y of items.slice(index + 1)) {
yield [x, y]
}
}
},
zoomToFit: function(paddingPercent, transitionDuration) {
var bounds = this.getVis().node().getBBox();
var width = bounds.width;
var height = bounds.height;
var midX = bounds.x + width/2;
var midY = bounds.y + height/2;
var svg = this.getVis().node().parentElement;
var svgRect = svg.getBoundingClientRect();
var fullWidth = svgRect.width;
var fullHeight = svgRect.height;
var scale = (paddingPercent || 0.8) / Math.max(width/fullWidth, height/fullHeight);
var translate = [fullWidth/2 - scale*midX, fullHeight/2 - scale*midY];
if (width<1) {return} // FIXME: something strange with spyral
d3.select(svg)
.transition()
.duration(transitionDuration || 500)
.call(this.getZoom().transform, d3.zoomIdentity.translate(translate[0],translate[1]).scale(scale));
}
});