diff --git a/rd_ui/app/app_layout.html b/rd_ui/app/app_layout.html
index 70d93b06d..9a922e163 100644
--- a/rd_ui/app/app_layout.html
+++ b/rd_ui/app/app_layout.html
@@ -88,6 +88,8 @@
+
+
diff --git a/rd_ui/app/scripts/vendor/cloud.js b/rd_ui/app/scripts/vendor/cloud.js
new file mode 100644
index 000000000..223d891a0
--- /dev/null
+++ b/rd_ui/app/scripts/vendor/cloud.js
@@ -0,0 +1,505 @@
+(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.d3||(g.d3 = {}));g=(g.layout||(g.layout = {}));g.cloud = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o> 5,
+ ch = 1 << 11;
+
+d3.cloud = function() {
+ var size = [256, 256],
+ text = cloudText,
+ font = cloudFont,
+ fontSize = cloudFontSize,
+ fontStyle = cloudFontNormal,
+ fontWeight = cloudFontNormal,
+ rotate = cloudRotate,
+ padding = cloudPadding,
+ spiral = archimedeanSpiral,
+ words = [],
+ timeInterval = Infinity,
+ event = dispatch("word", "end"),
+ timer = null,
+ random = Math.random,
+ cloud = {},
+ canvas = cloudCanvas;
+
+ cloud.canvas = function(_) {
+ return arguments.length ? (canvas = functor(_), cloud) : canvas;
+ };
+
+ cloud.start = function() {
+ var contextAndRatio = getContext(canvas()),
+ board = zeroArray((size[0] >> 5) * size[1]),
+ bounds = null,
+ n = words.length,
+ i = -1,
+ tags = [],
+ data = words.map(function(d, i) {
+ d.text = text.call(this, d, i);
+ d.font = font.call(this, d, i);
+ d.style = fontStyle.call(this, d, i);
+ d.weight = fontWeight.call(this, d, i);
+ d.rotate = rotate.call(this, d, i);
+ d.size = ~~fontSize.call(this, d, i);
+ d.padding = padding.call(this, d, i);
+ return d;
+ }).sort(function(a, b) { return b.size - a.size; });
+
+ if (timer) clearInterval(timer);
+ timer = setInterval(step, 0);
+ step();
+
+ return cloud;
+
+ function step() {
+ var start = Date.now();
+ while (Date.now() - start < timeInterval && ++i < n && timer) {
+ var d = data[i];
+ d.x = (size[0] * (random() + .5)) >> 1;
+ d.y = (size[1] * (random() + .5)) >> 1;
+ cloudSprite(contextAndRatio, d, data, i);
+ if (d.hasText && place(board, d, bounds)) {
+ tags.push(d);
+ event.word(d);
+ if (bounds) cloudBounds(bounds, d);
+ else bounds = [{x: d.x + d.x0, y: d.y + d.y0}, {x: d.x + d.x1, y: d.y + d.y1}];
+ // Temporary hack
+ d.x -= size[0] >> 1;
+ d.y -= size[1] >> 1;
+ }
+ }
+ if (i >= n) {
+ cloud.stop();
+ event.end(tags, bounds);
+ }
+ }
+ }
+
+ cloud.stop = function() {
+ if (timer) {
+ clearInterval(timer);
+ timer = null;
+ }
+ return cloud;
+ };
+
+ function getContext(canvas) {
+ canvas.width = canvas.height = 1;
+ var ratio = Math.sqrt(canvas.getContext("2d").getImageData(0, 0, 1, 1).data.length >> 2);
+ canvas.width = (cw << 5) / ratio;
+ canvas.height = ch / ratio;
+
+ var context = canvas.getContext("2d");
+ context.fillStyle = context.strokeStyle = "red";
+ context.textAlign = "center";
+
+ return {context: context, ratio: ratio};
+ }
+
+ function place(board, tag, bounds) {
+ var perimeter = [{x: 0, y: 0}, {x: size[0], y: size[1]}],
+ startX = tag.x,
+ startY = tag.y,
+ maxDelta = Math.sqrt(size[0] * size[0] + size[1] * size[1]),
+ s = spiral(size),
+ dt = random() < .5 ? 1 : -1,
+ t = -dt,
+ dxdy,
+ dx,
+ dy;
+
+ while (dxdy = s(t += dt)) {
+ dx = ~~dxdy[0];
+ dy = ~~dxdy[1];
+
+ if (Math.min(Math.abs(dx), Math.abs(dy)) >= maxDelta) break;
+
+ tag.x = startX + dx;
+ tag.y = startY + dy;
+
+ if (tag.x + tag.x0 < 0 || tag.y + tag.y0 < 0 ||
+ tag.x + tag.x1 > size[0] || tag.y + tag.y1 > size[1]) continue;
+ // TODO only check for collisions within current bounds.
+ if (!bounds || !cloudCollide(tag, board, size[0])) {
+ if (!bounds || collideRects(tag, bounds)) {
+ var sprite = tag.sprite,
+ w = tag.width >> 5,
+ sw = size[0] >> 5,
+ lx = tag.x - (w << 4),
+ sx = lx & 0x7f,
+ msx = 32 - sx,
+ h = tag.y1 - tag.y0,
+ x = (tag.y + tag.y0) * sw + (lx >> 5),
+ last;
+ for (var j = 0; j < h; j++) {
+ last = 0;
+ for (var i = 0; i <= w; i++) {
+ board[x + i] |= (last << msx) | (i < w ? (last = sprite[j * w + i]) >>> sx : 0);
+ }
+ x += sw;
+ }
+ delete tag.sprite;
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ cloud.timeInterval = function(_) {
+ return arguments.length ? (timeInterval = _ == null ? Infinity : _, cloud) : timeInterval;
+ };
+
+ cloud.words = function(_) {
+ return arguments.length ? (words = _, cloud) : words;
+ };
+
+ cloud.size = function(_) {
+ return arguments.length ? (size = [+_[0], +_[1]], cloud) : size;
+ };
+
+ cloud.font = function(_) {
+ return arguments.length ? (font = functor(_), cloud) : font;
+ };
+
+ cloud.fontStyle = function(_) {
+ return arguments.length ? (fontStyle = functor(_), cloud) : fontStyle;
+ };
+
+ cloud.fontWeight = function(_) {
+ return arguments.length ? (fontWeight = functor(_), cloud) : fontWeight;
+ };
+
+ cloud.rotate = function(_) {
+ return arguments.length ? (rotate = functor(_), cloud) : rotate;
+ };
+
+ cloud.text = function(_) {
+ return arguments.length ? (text = functor(_), cloud) : text;
+ };
+
+ cloud.spiral = function(_) {
+ return arguments.length ? (spiral = spirals[_] || _, cloud) : spiral;
+ };
+
+ cloud.fontSize = function(_) {
+ return arguments.length ? (fontSize = functor(_), cloud) : fontSize;
+ };
+
+ cloud.padding = function(_) {
+ return arguments.length ? (padding = functor(_), cloud) : padding;
+ };
+
+ cloud.random = function(_) {
+ return arguments.length ? (random = _, cloud) : random;
+ };
+
+ cloud.on = function() {
+ var value = event.on.apply(event, arguments);
+ return value === event ? cloud : value;
+ };
+
+ return cloud;
+};
+
+function cloudText(d) {
+ return d.text;
+}
+
+function cloudFont() {
+ return "serif";
+}
+
+function cloudFontNormal() {
+ return "normal";
+}
+
+function cloudFontSize(d) {
+ return Math.sqrt(d.value);
+}
+
+function cloudRotate() {
+ return (~~(Math.random() * 6) - 3) * 30;
+}
+
+function cloudPadding() {
+ return 1;
+}
+
+// Fetches a monochrome sprite bitmap for the specified text.
+// Load in batches for speed.
+function cloudSprite(contextAndRatio, d, data, di) {
+ if (d.sprite) return;
+ var c = contextAndRatio.context,
+ ratio = contextAndRatio.ratio;
+
+ c.clearRect(0, 0, (cw << 5) / ratio, ch / ratio);
+ var x = 0,
+ y = 0,
+ maxh = 0,
+ n = data.length;
+ --di;
+ while (++di < n) {
+ d = data[di];
+ c.save();
+ c.font = d.style + " " + d.weight + " " + ~~((d.size + 1) / ratio) + "px " + d.font;
+ var w = c.measureText(d.text + "m").width * ratio,
+ h = d.size << 1;
+ if (d.rotate) {
+ var sr = Math.sin(d.rotate * cloudRadians),
+ cr = Math.cos(d.rotate * cloudRadians),
+ wcr = w * cr,
+ wsr = w * sr,
+ hcr = h * cr,
+ hsr = h * sr;
+ w = (Math.max(Math.abs(wcr + hsr), Math.abs(wcr - hsr)) + 0x1f) >> 5 << 5;
+ h = ~~Math.max(Math.abs(wsr + hcr), Math.abs(wsr - hcr));
+ } else {
+ w = (w + 0x1f) >> 5 << 5;
+ }
+ if (h > maxh) maxh = h;
+ if (x + w >= (cw << 5)) {
+ x = 0;
+ y += maxh;
+ maxh = 0;
+ }
+ if (y + h >= ch) break;
+ c.translate((x + (w >> 1)) / ratio, (y + (h >> 1)) / ratio);
+ if (d.rotate) c.rotate(d.rotate * cloudRadians);
+ c.fillText(d.text, 0, 0);
+ if (d.padding) c.lineWidth = 2 * d.padding, c.strokeText(d.text, 0, 0);
+ c.restore();
+ d.width = w;
+ d.height = h;
+ d.xoff = x;
+ d.yoff = y;
+ d.x1 = w >> 1;
+ d.y1 = h >> 1;
+ d.x0 = -d.x1;
+ d.y0 = -d.y1;
+ d.hasText = true;
+ x += w;
+ }
+ var pixels = c.getImageData(0, 0, (cw << 5) / ratio, ch / ratio).data,
+ sprite = [];
+ while (--di >= 0) {
+ d = data[di];
+ if (!d.hasText) continue;
+ var w = d.width,
+ w32 = w >> 5,
+ h = d.y1 - d.y0;
+ // Zero the buffer
+ for (var i = 0; i < h * w32; i++) sprite[i] = 0;
+ x = d.xoff;
+ if (x == null) return;
+ y = d.yoff;
+ var seen = 0,
+ seenRow = -1;
+ for (var j = 0; j < h; j++) {
+ for (var i = 0; i < w; i++) {
+ var k = w32 * j + (i >> 5),
+ m = pixels[((y + j) * (cw << 5) + (x + i)) << 2] ? 1 << (31 - (i % 32)) : 0;
+ sprite[k] |= m;
+ seen |= m;
+ }
+ if (seen) seenRow = j;
+ else {
+ d.y0++;
+ h--;
+ j--;
+ y++;
+ }
+ }
+ d.y1 = d.y0 + seenRow;
+ d.sprite = sprite.slice(0, (d.y1 - d.y0) * w32);
+ }
+}
+
+// Use mask-based collision detection.
+function cloudCollide(tag, board, sw) {
+ sw >>= 5;
+ var sprite = tag.sprite,
+ w = tag.width >> 5,
+ lx = tag.x - (w << 4),
+ sx = lx & 0x7f,
+ msx = 32 - sx,
+ h = tag.y1 - tag.y0,
+ x = (tag.y + tag.y0) * sw + (lx >> 5),
+ last;
+ for (var j = 0; j < h; j++) {
+ last = 0;
+ for (var i = 0; i <= w; i++) {
+ if (((last << msx) | (i < w ? (last = sprite[j * w + i]) >>> sx : 0))
+ & board[x + i]) return true;
+ }
+ x += sw;
+ }
+ return false;
+}
+
+function cloudBounds(bounds, d) {
+ var b0 = bounds[0],
+ b1 = bounds[1];
+ if (d.x + d.x0 < b0.x) b0.x = d.x + d.x0;
+ if (d.y + d.y0 < b0.y) b0.y = d.y + d.y0;
+ if (d.x + d.x1 > b1.x) b1.x = d.x + d.x1;
+ if (d.y + d.y1 > b1.y) b1.y = d.y + d.y1;
+}
+
+function collideRects(a, b) {
+ return a.x + a.x1 > b[0].x && a.x + a.x0 < b[1].x && a.y + a.y1 > b[0].y && a.y + a.y0 < b[1].y;
+}
+
+function archimedeanSpiral(size) {
+ var e = size[0] / size[1];
+ return function(t) {
+ return [e * (t *= .1) * Math.cos(t), t * Math.sin(t)];
+ };
+}
+
+function rectangularSpiral(size) {
+ var dy = 4,
+ dx = dy * size[0] / size[1],
+ x = 0,
+ y = 0;
+ return function(t) {
+ var sign = t < 0 ? -1 : 1;
+ // See triangular numbers: T_n = n * (n + 1) / 2.
+ switch ((Math.sqrt(1 + 4 * sign * t) - sign) & 3) {
+ case 0: x += dx; break;
+ case 1: y += dy; break;
+ case 2: x -= dx; break;
+ default: y -= dy; break;
+ }
+ return [x, y];
+ };
+}
+
+// TODO reuse arrays?
+function zeroArray(n) {
+ var a = [],
+ i = -1;
+ while (++i < n) a[i] = 0;
+ return a;
+}
+
+function cloudCanvas() {
+ return document.createElement("canvas");
+}
+
+function functor(d) {
+ return typeof d === "function" ? d : function() { return d; };
+}
+
+var spirals = {
+ archimedean: archimedeanSpiral,
+ rectangular: rectangularSpiral
+};
+
+},{"d3-dispatch":2}],2:[function(require,module,exports){
+(function (global, factory) {
+ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
+ typeof define === 'function' && define.amd ? define(['exports'], factory) :
+ factory((global.dispatch = {}));
+}(this, function (exports) { 'use strict';
+
+ function Dispatch(types) {
+ var i = -1,
+ n = types.length,
+ callbacksByType = {},
+ callbackByName = {},
+ type,
+ that = this;
+
+ that.on = function(type, callback) {
+ type = parseType(type);
+
+ // Return the current callback, if any.
+ if (arguments.length < 2) {
+ return (callback = callbackByName[type.name]) && callback.value;
+ }
+
+ // If a type was specified…
+ if (type.type) {
+ var callbacks = callbacksByType[type.type],
+ callback0 = callbackByName[type.name],
+ i;
+
+ // Remove the current callback, if any, using copy-on-remove.
+ if (callback0) {
+ callback0.value = null;
+ i = callbacks.indexOf(callback0);
+ callbacksByType[type.type] = callbacks = callbacks.slice(0, i).concat(callbacks.slice(i + 1));
+ delete callbackByName[type.name];
+ }
+
+ // Add the new callback, if any.
+ if (callback) {
+ callback = {value: callback};
+ callbackByName[type.name] = callback;
+ callbacks.push(callback);
+ }
+ }
+
+ // Otherwise, if a null callback was specified, remove all callbacks with the given name.
+ else if (callback == null) {
+ for (var otherType in callbacksByType) {
+ if (callback = callbackByName[otherType + type.name]) {
+ callback.value = null;
+ var callbacks = callbacksByType[otherType], i = callbacks.indexOf(callback);
+ callbacksByType[otherType] = callbacks.slice(0, i).concat(callbacks.slice(i + 1));
+ delete callbackByName[callback.name];
+ }
+ }
+ }
+
+ return that;
+ };
+
+ while (++i < n) {
+ type = types[i] + "";
+ if (!type || (type in that)) throw new Error("illegal or duplicate type: " + type);
+ callbacksByType[type] = [];
+ that[type] = applier(type);
+ }
+
+ function parseType(type) {
+ var i = (type += "").indexOf("."), name = type;
+ if (i >= 0) type = type.slice(0, i); else name += ".";
+ if (type && !callbacksByType.hasOwnProperty(type)) throw new Error("unknown type: " + type);
+ return {type: type, name: name};
+ }
+
+ function applier(type) {
+ return function() {
+ var callbacks = callbacksByType[type], // Defensive reference; copy-on-remove.
+ callback,
+ callbackValue,
+ i = -1,
+ n = callbacks.length;
+
+ while (++i < n) {
+ if (callbackValue = (callback = callbacks[i]).value) {
+ callbackValue.apply(this, arguments);
+ }
+ }
+
+ return that;
+ };
+ }
+ }
+
+ function dispatch() {
+ return new Dispatch(arguments);
+ }
+
+ dispatch.prototype = Dispatch.prototype; // allow instanceof
+
+ exports.dispatch = dispatch;
+
+}));
+},{}]},{},[1])(1)
+});
diff --git a/rd_ui/app/scripts/visualizations/wordcloud.js b/rd_ui/app/scripts/visualizations/wordcloud.js
new file mode 100644
index 000000000..e2bc5404c
--- /dev/null
+++ b/rd_ui/app/scripts/visualizations/wordcloud.js
@@ -0,0 +1,97 @@
+(function () {
+ var wordCloudVisualization = angular.module('redash.visualization');
+
+ wordCloudVisualization.config(['VisualizationProvider', function (VisualizationProvider) {
+ VisualizationProvider.registerVisualization({
+ type: 'WORD_CLOUD',
+ name: 'Word Cloud',
+ renderTemplate: '',
+ editorTemplate: ''
+ });
+ }]);
+
+ wordCloudVisualization.directive('wordCloudRenderer', function () {
+ return {
+ restrict: 'E',
+ link: function($scope, elem, attrs) {
+
+ reloadCloud = function () {
+
+ if (!angular.isDefined($scope.queryResult)) retun;
+ data = $scope.queryResult.getData();
+ cloud = d3.cloud;
+
+ wordsHash = {};
+ if($scope.visualization.options.column){
+ data.map(function(d) {
+ d[$scope.visualization.options.column]
+ .toString()
+ .split(' ')
+ .map(function(d) {
+ if (d in wordsHash) {
+ wordsHash[d]+=1;
+ } else {
+ wordsHash[d]=1;
+ }
+ })
+ })
+ }
+
+ wordList = [];
+ for(var key in wordsHash) {
+ wordList.push({text: key, size: 10 + Math.pow(wordsHash[key],2)});
+ }
+
+ var fill = d3.scale.category20();
+
+ var layout = cloud()
+ .size([500, 500])
+ .words(wordList)
+ .padding(5)
+ .rotate(function() { return ~~(Math.random() * 2) * 90; })
+ .font("Impact")
+ .fontSize(function(d) { return d.size; })
+ .on("end", draw);
+
+ layout.start();
+
+ function draw(words) {
+ d3.select(elem[0].parentNode)
+ .select("svg")
+ .remove();
+
+ d3.select(elem[0].parentNode)
+ .append("svg")
+ .attr("width", layout.size()[0])
+ .attr("height", layout.size()[1])
+ .append("g")
+ .attr("transform", "translate(" + layout.size()[0] / 2 + "," + layout.size()[1] / 2 + ")")
+ .selectAll("text")
+ .data(words)
+ .enter().append("text")
+ .style("font-size", function(d) { return d.size + "px"; })
+ .style("font-family", "Impact")
+ .style("fill", function(d, i) { return fill(i); })
+ .attr("text-anchor", "middle")
+ .attr("transform", function(d) {
+ return "translate(" + [d.x, d.y] + ")rotate(" + d.rotate + ")";
+ })
+ .text(function(d) { return d.text; });
+ }
+
+ }
+
+ $scope.$watch('queryResult && queryResult.getData()', reloadCloud);
+ $scope.$watch('visualization.options.column', reloadCloud);
+ }
+ }
+ });
+
+ wordCloudVisualization.directive('wordCloudEditor', function() {
+ return {
+ restrict: 'E',
+ templateUrl: '/views/visualizations/word_cloud_editor.html'
+ };
+ });
+
+})();
diff --git a/rd_ui/app/views/visualizations/word_cloud.html b/rd_ui/app/views/visualizations/word_cloud.html
new file mode 100644
index 000000000..b80e6e067
--- /dev/null
+++ b/rd_ui/app/views/visualizations/word_cloud.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/rd_ui/app/views/visualizations/word_cloud_editor.html b/rd_ui/app/views/visualizations/word_cloud_editor.html
new file mode 100644
index 000000000..84206dd9e
--- /dev/null
+++ b/rd_ui/app/views/visualizations/word_cloud_editor.html
@@ -0,0 +1,8 @@
+