- 1 :
/**
- 2 :
* Mandala is a conceptual visualization that shows the relationships between terms and documents.
- 3 :
*
- 4 :
* @example
- 5 :
*
- 6 :
* let config = {
- 7 :
* "labels": true,
- 8 :
* "query": null,
- 9 :
* "stopList": "auto",
- 10 :
* };
- 11 :
*
- 12 :
* loadCorpus("austen").tool("Mandala", config);
- 13 :
*
- 14 :
* @class Mandala
- 15 :
* @tutorial mandala
- 16 :
* @memberof Tools
- 17 :
*/
- 18 :
Ext.define('Voyant.panel.Mandala', {
- 19 :
extend: 'Ext.panel.Panel',
- 20 :
mixins: ['Voyant.panel.Panel'],
- 21 :
alias: 'widget.mandala',
- 22 :
statics: {
- 23 :
i18n: {
- 24 :
},
- 25 :
api: {
- 26 :
/**
- 27 :
* @memberof Tools.Mandala
- 28 :
* @instance
- 29 :
* @property {stopList}
- 30 :
* @default
- 31 :
*/
- 32 :
stopList: 'auto',
- 33 :
- 34 :
/**
- 35 :
* @memberof Tools.Mandala
- 36 :
* @instance
- 37 :
* @property {query}
- 38 :
*/
- 39 :
query: undefined,
- 40 :
- 41 :
/**
- 42 :
* @memberof Tools.Mandala
- 43 :
* @instance
- 44 :
* @property {Boolean} labels Whether or not labels should be shown.
- 45 :
* @default
- 46 :
*/
- 47 :
labels: true
- 48 :
- 49 :
},
- 50 :
glyph: 'xf1db@FontAwesome'
- 51 :
},
- 52 :
- 53 :
gutter: 5,
- 54 :
- 55 :
textFont: '12px sans-serif',
- 56 :
- 57 :
config: {
- 58 :
options: [{xtype: 'stoplistoption'},{xtype: 'categoriesoption'}]
- 59 :
},
- 60 :
- 61 :
constructor: function() {
- 62 :
- 63 :
this.mixins['Voyant.util.Localization'].constructor.apply(this, arguments);
- 64 :
Ext.apply(this, {
- 65 :
title: this.localize('title'),
- 66 :
html: '<div style="text-align: center"><canvas width="800" height="600"></canvas></div>',
- 67 :
dockedItems: [{
- 68 :
dock: 'bottom',
- 69 :
xtype: 'toolbar',
- 70 :
overflowHandler: 'scroller',
- 71 :
items: [{
- 72 :
text: this.localize('add'),
- 73 :
glyph: 'xf067@FontAwesome',
- 74 :
handler: function() {
- 75 :
this.editMagnet();
- 76 :
},
- 77 :
scope: this
- 78 :
},{
- 79 :
text: this.localize('clear'),
- 80 :
glyph: 'xf014@FontAwesome',
- 81 :
handler: function() {
- 82 :
this.setApiParam('query', undefined);
- 83 :
this.updateFromQueries(true);
- 84 :
this.editMagnet();
- 85 :
},
- 86 :
scope: this
- 87 :
},{
- 88 :
xtype: 'checkbox',
- 89 :
boxLabel: this.localize('labels'),
- 90 :
listeners: {
- 91 :
render: function(cmp) {
- 92 :
cmp.setValue(this.getApiParam("labels")===true);
- 93 :
Ext.tip.QuickTipManager.register({
- 94 :
target: cmp.getEl(),
- 95 :
text: this.localize('labelsTip')
- 96 :
});
- 97 :
- 98 :
},
- 99 :
beforedestroy: function(cmp) {
- 100 :
Ext.tip.QuickTipManager.unregister(cmp.getEl());
- 101 :
},
- 102 :
change: function(cmp, val) {
- 103 :
this.setApiParam('labels', val);
- 104 :
this.draw();
- 105 :
},
- 106 :
scope: this
- 107 :
}
- 108 :
}]
- 109 :
}]
- 110 :
});
- 111 :
this.callParent(arguments);
- 112 :
this.mixins['Voyant.panel.Panel'].constructor.apply(this, arguments);
- 113 :
- 114 :
this.on('boxready', function(cmp) {
- 115 :
var canvas = this.getTargetEl().dom.querySelector("canvas");
- 116 :
var me = this;
- 117 :
canvas.addEventListener('mousemove', function(evt) {
- 118 :
var rect = canvas.getBoundingClientRect(), x = evt.clientX - rect.left, y = evt.clientY - rect.top,
- 119 :
change = false, docRadius = parseInt(me.textFont)/2;
- 120 :
if (me.documents) {
- 121 :
me.documents.forEach(function(doc) {
- 122 :
var isHovering = x > doc.x-docRadius && x < doc.x+docRadius && y > doc.y-docRadius && y < doc.y+docRadius;
- 123 :
if (isHovering!=doc.isHovering) {change = true;}
- 124 :
doc.isHovering = isHovering;
- 125 :
})
- 126 :
}
- 127 :
radius = parseInt(me.textFont)/2;
- 128 :
for (term in me.magnets) {
- 129 :
var isHovering = x > me.magnets[term].x-radius && x < me.magnets[term].x+radius && y > me.magnets[term].y-radius && y < me.magnets[term].y+radius;
- 130 :
if (isHovering!=me.magnets[term].isHovering) {change = true;}
- 131 :
me.magnets[term].isHovering = isHovering;
- 132 :
}
- 133 :
if (change) {
- 134 :
me.draw();
- 135 :
}
- 136 :
}, false);
- 137 :
canvas.addEventListener('click', function(evt) {
- 138 :
var rect = canvas.getBoundingClientRect(), x = evt.clientX - rect.left, y = evt.clientY - rect.top,
- 139 :
docRadius = parseInt(me.textFont)/2;
- 140 :
for (term in me.magnets) {
- 141 :
if (x > me.magnets[term].x-radius && x < me.magnets[term].x+radius && y > me.magnets[term].y-radius && y < me.magnets[term].y+radius) {
- 142 :
me.editMagnet(term);
- 143 :
}
- 144 :
}
- 145 :
}, false);
- 146 :
})
- 147 :
- 148 :
this.on('loadedCorpus', function(src, corpus) {
- 149 :
this.documents = [];
- 150 :
var canvas = this.getTargetEl().dom.querySelector("canvas"), ctx = canvas.getContext("2d"), radius = canvas.width/2;
- 151 :
ctx.font = this.textFont;
- 152 :
corpus.getDocuments().each(function(document) {
- 153 :
var label = document.getTinyTitle();
- 154 :
this.documents.push({
- 155 :
doc: document,
- 156 :
label: label,
- 157 :
width: ctx.measureText(label).width,
- 158 :
x: radius,
- 159 :
y: radius,
- 160 :
matches: [],
- 161 :
isHovering: false
- 162 :
});
- 163 :
}, this);
- 164 :
this.updateDocs(canvas);
- 165 :
this.draw();
- 166 :
this.updateFromQueries();
- 167 :
}, this);
- 168 :
- 169 :
this.on("resize", function() {
- 170 :
var canvas = this.getTargetEl().dom.querySelector("canvas"),
- 171 :
diam = Math.min(this.getTargetEl().getWidth(), this.getTargetEl().getHeight());
- 172 :
canvas.width = diam;
- 173 :
canvas.height = diam;
- 174 :
this.updateMagnets();
- 175 :
this.updateDocs();
- 176 :
this.draw(canvas)
- 177 :
})
- 178 :
},
- 179 :
- 180 :
editMagnet: function(term) {
- 181 :
var me = this, currentTerms = Ext.Array.from(me.getApiParam('query'));
- 182 :
Ext.create('Ext.window.Window', {
- 183 :
title: this.localize("EditMagnet"),
- 184 :
modal: true,
- 185 :
items: {
- 186 :
xtype: 'form',
- 187 :
width: 300,
- 188 :
items: [{
- 189 :
xtype: 'querysearchfield',
- 190 :
corpus: this.getCorpus(),
- 191 :
store: this.getCorpus().getCorpusTerms({
- 192 :
proxy: {
- 193 :
extraParams: {
- 194 :
stopList: this.getApiParam('stopList')
- 195 :
}
- 196 :
}
- 197 :
}),
- 198 :
stopList: this.getApiParam('stopList'),
- 199 :
listeners: {
- 200 :
afterrender: function(field) {
- 201 :
if (term) {
- 202 :
var termObj = new Ext.create("Voyant.data.model.CorpusTerm", {
- 203 :
term: term
- 204 :
});
- 205 :
field.getStore().loadData(termObj, true)
- 206 :
field.setValue(termObj);
- 207 :
}
- 208 :
}
- 209 :
}
- 210 :
},{
- 211 :
xtype: "numberfield",
- 212 :
fieldLabel: me.localize('rotateClockwise'),
- 213 :
minValue: 0,
- 214 :
maxValue: currentTerms.length-1,
- 215 :
value: 0,
- 216 :
stepValue: 1,
- 217 :
width: 200,
- 218 :
name: "rotate"
- 219 :
}],
- 220 :
buttons: [{
- 221 :
text: this.localize("remove"),
- 222 :
glyph: 'xf0e2@FontAwesome',
- 223 :
flex: 1,
- 224 :
ui: 'default-toolbar',
- 225 :
handler: function(btn) {
- 226 :
var queries = Ext.Array.filter(Ext.Array.from(me.getApiParam('query')), function(query) {
- 227 :
return query!=term
- 228 :
});
- 229 :
me.setApiParam('query', queries);
- 230 :
me.updateFromQueries(queries.length==0);
- 231 :
btn.up('window').close();
- 232 :
},
- 233 :
scope: this
- 234 :
},{xtype: 'tbfill'}, {
- 235 :
text: this.localize("cancel"),
- 236 :
ui: 'default-toolbar',
- 237 :
glyph: 'xf00d@FontAwesome',
- 238 :
flex: 1,
- 239 :
handler: function(btn) {
- 240 :
btn.up('window').close();
- 241 :
}
- 242 :
},{
- 243 :
text: this.localize("update"),
- 244 :
glyph: 'xf00c@FontAwesome',
- 245 :
flex: 1,
- 246 :
handler: function(btn) {
- 247 :
var val = btn.up('window').down('querysearchfield').getValue().join("|")
- 248 :
if (val) {
- 249 :
- 250 :
// start by updating the term in place
- 251 :
var position = -1;
- 252 :
for (var i=0; i<currentTerms.length; i++) {
- 253 :
if (term==currentTerms[i]) {
- 254 :
position=i;
- 255 :
currentTerms[i]=val;
- 256 :
- 257 :
// see if we need to shift
- 258 :
var rotate = btn.up('window').down('numberfield').getValue();
- 259 :
if (rotate) {
- 260 :
currentTerms.splice(i, 1);
- 261 :
var newpos = i+rotate;
- 262 :
if (newpos>currentTerms.length) {newpos-=currentTerms.length+1;}
- 263 :
currentTerms.splice(newpos, 0, val);
- 264 :
}
- 265 :
break
- 266 :
- 267 :
}
- 268 :
}
- 269 :
if (position==-1) { // not sure why it couldn't be found
- 270 :
currentTerms.push(val);
- 271 :
}
- 272 :
}
- 273 :
- 274 :
me.setApiParam('query', currentTerms);
- 275 :
me.updateFromQueries(currentTerms.length==0);
- 276 :
btn.up('window').close();
- 277 :
},
- 278 :
scope: this
- 279 :
}]
- 280 :
},
- 281 :
bodyPadding: 5
- 282 :
}).show()
- 283 :
},
- 284 :
- 285 :
updateFromQueries: function(allowEmpty) {
- 286 :
this.magnets = undefined;
- 287 :
this.documents.forEach(function(doc) {doc.matches=[]})
- 288 :
this.updateDocs();
- 289 :
this.draw();
- 290 :
if (this.documents) {
- 291 :
var params = this.getApiParams();
- 292 :
if (!params.query) {params.limit=10;}
- 293 :
var queries = Ext.Array.from(this.getApiParam('query'));
- 294 :
if (!allowEmpty || queries.length>0) {
- 295 :
this.getCorpus().getCorpusTerms().load({
- 296 :
params: Ext.apply(params, {withDistributions: true}),
- 297 :
callback: function(records) {
- 298 :
var canvas = this.getTargetEl().dom.querySelector("canvas"), ctx = canvas.getContext("2d");
- 299 :
diam = canvas.width, rad = diam /2;
- 300 :
ctx.font = this.textFont;
- 301 :
var magnets = {};
- 302 :
for (var i=0, len=records.length; i<len; i++) {
- 303 :
var term = records[i].getTerm();
- 304 :
records[i].getDistributions().forEach(function(val, i) {
- 305 :
if (val>0) {
- 306 :
this.documents[i].matches.push(term)
- 307 :
}
- 308 :
}, this);
- 309 :
magnets[term] = {
- 310 :
record: records[i],
- 311 :
colour: this.getApplication().getColor(i),
- 312 :
width: ctx.measureText(term).width,
- 313 :
isHovering: false
- 314 :
}
- 315 :
}
- 316 :
- 317 :
this.magnets = {};
- 318 :
- 319 :
// try ordering by queries
- 320 :
queries.forEach(function(query) {
- 321 :
if (magnets[query]) {
- 322 :
this.magnets[query] = magnets[query]
- 323 :
delete magnets[query]
- 324 :
}
- 325 :
}, this);
- 326 :
- 327 :
// now for any leftovers
- 328 :
for (term in magnets) {
- 329 :
this.magnets[term] = magnets[term]
- 330 :
}
- 331 :
- 332 :
this.setApiParam('query', Object.keys(this.magnets))
- 333 :
this.updateMagnets();
- 334 :
this.updateDocs();
- 335 :
this.draw();
- 336 :
},
- 337 :
scope: this
- 338 :
})
- 339 :
}
- 340 :
}
- 341 :
},
- 342 :
- 343 :
updateMagnets: function(canvas) {
- 344 :
var canvas = this.getTargetEl().dom.querySelector("canvas"), diam = canvas.width, rad = diam /2;
- 345 :
var len = Object.keys(this.magnets || {}).length;
- 346 :
var i = 0;
- 347 :
for (var term in this.magnets) {
- 348 :
Ext.apply(this.magnets[term], {
- 349 :
x: rad+((rad-this.gutter-50) * Math.cos(2 * Math.PI * i / len)),
- 350 :
y: rad+((rad-this.gutter-50) * Math.sin(2 * Math.PI * i / len))
- 351 :
})
- 352 :
i++;
- 353 :
}
- 354 :
},
- 355 :
- 356 :
updateDocs: function(canvas) {
- 357 :
canvas = canvas || this.getTargetEl().dom.querySelector("canvas"), diam = canvas.width, rad = diam /2;
- 358 :
var notMatching = [];
- 359 :
if (this.documents) {
- 360 :
this.documents.forEach(function(doc, i) {
- 361 :
if (Ext.Array.from(doc.matches).length==0) {notMatching.push(i);} // will be set around perimeter below
- 362 :
else if (Ext.Array.from(doc.matches).length==1) { // try to set it away from magnet
- 363 :
var x = (Math.random()*15)+15, y = (Math.random()*15)+15;
- 364 :
doc.targetX = this.magnets[doc.matches[0]].x + (Math.round(Math.random())==0 ? x : -x);
- 365 :
doc.targetY = this.magnets[doc.matches[0]].y + (Math.round(Math.random())==0 ? y : -y);
- 366 :
} else {
- 367 :
// determine the weighted position
- 368 :
var x = 0, y = 0,
- 369 :
vals = doc.matches.map(function(term) {return this.magnets[term].record.getDistributions()[i]}, this),
- 370 :
min = Ext.Array.min(vals), max = Ext.Array.max(vals);
- 371 :
var weights = 0;
- 372 :
doc.matches.forEach(function(term, j) {
- 373 :
weight = max==min ? 1 : ((vals[j]-min)+min)/((max-min)+min);
- 374 :
weights += weight;
- 375 :
x += this.magnets[term].x*weight;
- 376 :
y += this.magnets[term].y*weight;
- 377 :
- 378 :
}, this)
- 379 :
doc.targetX = x/weights
- 380 :
doc.targetY = y/weights
- 381 :
}
- 382 :
}, this);
- 383 :
- 384 :
// set around perimeter
- 385 :
for (var i=0, len=notMatching.length; i<len; i++) {
- 386 :
Ext.apply(this.documents[i], {
- 387 :
targetX: rad+((rad-this.gutter) * Math.cos(2 * Math.PI * i / len)),
- 388 :
targetY: rad+((rad-this.gutter) * Math.sin(2 * Math.PI * i / len))
- 389 :
})
- 390 :
}
- 391 :
}
- 392 :
},
- 393 :
- 394 :
draw: function(canvas, ctx) {
- 395 :
canvas = canvas || this.getTargetEl().dom.querySelector("canvas");
- 396 :
ctx = ctx || canvas.getContext("2d");
- 397 :
ctx.font = this.textFont;
- 398 :
var radius = canvas.width/2;
- 399 :
ctx.clearRect(0,0,canvas.width,canvas.height);
- 400 :
var labels = this.getApiParam('labels');
- 401 :
- 402 :
// draw circle
- 403 :
ctx.beginPath();
- 404 :
ctx.strokeStyle = "rgba(0,0,0,.1)"
- 405 :
ctx.fillStyle = "rgba(0,0,0,.02)"
- 406 :
ctx.arc(radius, radius, radius-this.gutter, 0, 2 * Math.PI, false);
- 407 :
ctx.fill();
- 408 :
ctx.lineWidth = 2;
- 409 :
ctx.stroke();
- 410 :
- 411 :
// determine if we're animating a move and need to come back
- 412 :
var needRedraw = false;
- 413 :
- 414 :
// draw documents
- 415 :
- 416 :
if (this.documents && this.documents.length>0) {
- 417 :
var needMove = false;
- 418 :
- 419 :
var noHovering = Ext.Array.each(this.documents, function(doc) {
- 420 :
return !doc.isHovering
- 421 :
}, this);
- 422 :
- 423 :
if (noHovering===true) {
- 424 :
noHovering = Ext.Array.each(Object.keys(this.magnets || {}), function(term) {
- 425 :
return !this.magnets[term].isHovering
- 426 :
}, this);
- 427 :
}
- 428 :
// go through a first time to draw connecting lines underneath
- 429 :
var hoveringTerms = {}; hoveringDocs = [];
- 430 :
this.documents.forEach(function(document, j) {
- 431 :
document.matches.forEach(function(term, i) {
- 432 :
ctx.beginPath();
- 433 :
ctx.moveTo(document.x, document.y);
- 434 :
ctx.lineTo(this.magnets[term].x, this.magnets[term].y);
- 435 :
if (noHovering===true) {
- 436 :
ctx.strokeStyle = "rgba("+this.magnets[term].colour.join(",")+",.1)";
- 437 :
} else {
- 438 :
if (document.isHovering || this.magnets[term].isHovering) {
- 439 :
hoveringDocs[j]=true;
- 440 :
hoveringTerms[term]=true;
- 441 :
ctx.strokeStyle = "rgba("+this.magnets[term].colour.join(",")+",.5)";
- 442 :
} else {
- 443 :
ctx.strokeStyle = "rgba(0,0,0,.02)";
- 444 :
}
- 445 :
}
- 446 :
ctx.stroke();
- 447 :
}, this);
- 448 :
}, this);
- 449 :
- 450 :
// now a second time for labels/markers
- 451 :
var halfSize = parseInt(this.textFont)/2, height = parseInt(this.textFont)+4;
- 452 :
this.documents.forEach(function(document, i) {
- 453 :
- 454 :
// draw marker/label
- 455 :
if (labels || document.isHovering || hoveringDocs[i]==true) {
- 456 :
var width = document.width+4;
- 457 :
ctx.fillStyle = document.isHovering || hoveringDocs[i]==true || noHovering===true ? "white" : "rgba(255,255,255,.05)"
- 458 :
ctx.fillRect(document.x-(width/2), document.y-(height/2), width, height);
- 459 :
ctx.strokeStyle = document.isHovering || hoveringDocs[i]==true || noHovering===true ? "rgba(0,0,0,.2)" : "rgba(0,0,0,.05)"
- 460 :
ctx.strokeRect(document.x-(width/2), document.y-(height/2), width, height);
- 461 :
ctx.textAlign = "center";
- 462 :
ctx.fillStyle = document.isHovering || hoveringDocs[i]==true || noHovering===true ? "rgba(0,0,0,.8)" : "rgba(0,0,0,.05)";
- 463 :
ctx.fillText(document.label, document.x, document.y);
- 464 :
} else {
- 465 :
ctx.beginPath();
- 466 :
ctx.fillStyle = "rgba(0,0,0,.8)"
- 467 :
ctx.arc(document.x, document.y, halfSize, 0, 2 * Math.PI);
- 468 :
ctx.fill();
- 469 :
ctx.stroke();
- 470 :
}
- 471 :
- 472 :
// determine if we need to move
- 473 :
var dx = Math.abs(document.x - document.targetX), dy = Math.abs(document.y- document.targetY)
- 474 :
if (dx!=0 || dy!=0) {
- 475 :
if (dx<1) {document.x = document.targetX}
- 476 :
else {
- 477 :
dx/=2;
- 478 :
document.x = document.x > document.targetX ? document.x-dx : document.x+dx;
- 479 :
}
- 480 :
if (dy<1) {document.y = document.targetY}
- 481 :
else {
- 482 :
dy/=2;
- 483 :
document.y = document.y > document.targetY ? document.y-dy : document.y+dy;
- 484 :
}
- 485 :
needRedraw = true;
- 486 :
}
- 487 :
}, this);
- 488 :
- 489 :
// now magnets
- 490 :
var i = 0, height = parseInt(this.textFont)+4;
- 491 :
ctx.textAlign = "center";
- 492 :
ctx.textBaseline="middle";
- 493 :
for (var term in this.magnets) {
- 494 :
if (labels || term in hoveringTerms || this.magnets[term].isHovering) {
- 495 :
var width = this.magnets[term].width+4;
- 496 :
ctx.fillStyle = term in hoveringTerms || this.magnets[term].isHovering || noHovering===true ? "white" : "rgba(255,255,255,.05)";
- 497 :
ctx.fillRect(this.magnets[term].x-(width/2), this.magnets[term].y-(height/2), width, height);
- 498 :
ctx.strokeStyle = term in hoveringTerms || this.magnets[term].isHovering || noHovering===true ? "rgb("+this.magnets[term].colour.join(",")+")" : "rgba(0,0,0,.05)";
- 499 :
ctx.strokeRect(this.magnets[term].x-(width/2), this.magnets[term].y-(height/2), width, height);
- 500 :
ctx.textAlign = "center";
- 501 :
ctx.fillStyle = term in hoveringTerms || this.magnets[term].isHovering || noHovering===true ?"rgba(0,0,0,.8)" : "rgba(0,0,0,.05)";
- 502 :
ctx.fillText(term, this.magnets[term].x, this.magnets[term].y);
- 503 :
} else {
- 504 :
ctx.beginPath();
- 505 :
ctx.fillStyle = "rgb("+this.magnets[term].colour.join(",")+")"
- 506 :
ctx.strokeStyle = "rgb("+this.magnets[term].colour.join(",")+")"
- 507 :
ctx.arc(this.magnets[term].x, this.magnets[term].y, 12, 0, 2 * Math.PI);
- 508 :
ctx.fill();
- 509 :
ctx.stroke();
- 510 :
}
- 511 :
}
- 512 :
}
- 513 :
- 514 :
if (needRedraw) {
- 515 :
var me = this;
- 516 :
setTimeout(function() {
- 517 :
me.draw();
- 518 :
}, 100);
- 519 :
} else if (this.documents) {
- 520 :
var minDist = Math.max(radius/this.documents.length, 50), spring = .1
- 521 :
for (var i=0, len=this.documents.length; i<len; i++) {
- 522 :
for (var j=0; j<len; j++) {
- 523 :
if (i<j) {
- 524 :
- 525 :
var dx = this.documents[i].x - this.documents[j].x,
- 526 :
dy = this.documents[i].y - this.documents[j].y,
- 527 :
dist = Math.sqrt(dx * dx + dy * dy);
- 528 :
if (dist < minDist) {
- 529 :
var ax = dx * spring, ay = dy * spring;
- 530 :
this.documents[i].targetX += ax;
- 531 :
this.documents[j].targetX -= ax;
- 532 :
this.documents[i].targetY += ay;
- 533 :
this.documents[j].targetY -= ay;
- 534 :
needRedraw = true;
- 535 :
}
- 536 :
}
- 537 :
}
- 538 :
}
- 539 :
if (needRedraw) {
- 540 :
var me = this;
- 541 :
setTimeout(function() {
- 542 :
me.draw();
- 543 :
}, 100);
- 544 :
}
- 545 :
}
- 546 :
}
- 547 :
- 548 :
});