From c817690ab9431222f65d3df28923d04a736133b8 Mon Sep 17 00:00:00 2001 From: Jeremy Ruston Date: Tue, 30 Jul 2013 12:36:36 +0100 Subject: [PATCH] First pass at WordCloud plugin --- editions/d3demo/tiddlers/HelloThere.tid | 6 + plugins/tiddlywiki/d3/cloudwidget.js | 103 +++++ plugins/tiddlywiki/d3/files/LICENSE | 26 ++ plugins/tiddlywiki/d3/files/cloud/LICENSE | 26 ++ .../d3/files/cloud/d3.layout.cloud.js | 401 ++++++++++++++++++ plugins/tiddlywiki/d3/files/tiddlywiki.files | 8 + 6 files changed, 570 insertions(+) create mode 100644 plugins/tiddlywiki/d3/cloudwidget.js create mode 100644 plugins/tiddlywiki/d3/files/LICENSE create mode 100644 plugins/tiddlywiki/d3/files/cloud/LICENSE create mode 100644 plugins/tiddlywiki/d3/files/cloud/d3.layout.cloud.js diff --git a/editions/d3demo/tiddlers/HelloThere.tid b/editions/d3demo/tiddlers/HelloThere.tid index ff68e03da..e2b236d6a 100644 --- a/editions/d3demo/tiddlers/HelloThere.tid +++ b/editions/d3demo/tiddlers/HelloThere.tid @@ -2,6 +2,12 @@ title: HelloThere This is a demo of TiddlyWiki5 incorporating a plugin for the [[D3.js]] visualization library. +! Word Cloud + +<$d3cloud/> + +! Bar Chart + <$d3bar grouped={{$:/grouped}} data="GraphData"/> <$button set="$:/grouped" setTo="yes">grouped <$button set="$:/grouped" setTo="no">stacked diff --git a/plugins/tiddlywiki/d3/cloudwidget.js b/plugins/tiddlywiki/d3/cloudwidget.js new file mode 100644 index 000000000..b79c08431 --- /dev/null +++ b/plugins/tiddlywiki/d3/cloudwidget.js @@ -0,0 +1,103 @@ +/*\ +title: $:/plugins/tiddlywiki/d3/cloudwidget.js +type: application/javascript +module-type: widget + +A widget for displaying word clouds. Derived from https://github.com/jasondavies/d3-cloud + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +var d3 = require("$:/plugins/tiddlywiki/d3/d3.js").d3; + +if($tw.browser) { + // Frightful hack to give the cloud plugin the global d3 variable it needs + window.d3 = d3; + d3.layout.cloud = require("$:/plugins/tiddlywiki/d3/d3.layout.cloud.js").cloud; +} + +var CloudWidget = function(renderer) { + // Save state + this.renderer = renderer; + // Generate child nodes + this.generate(); +}; + +CloudWidget.prototype.generate = function() { + // Get the parameters + this.data = this.renderer.getAttribute("data"); + this.grouped = this.renderer.getAttribute("grouped","no"); + // Set the return element + this.tag = "div"; + this.attributes = { + "class": "tw-cloudwidget" + }; +}; + +CloudWidget.prototype.postRenderInDom = function() { + this.updateChart = this.createChart(); +}; + +CloudWidget.prototype.refreshInDom = function(changedAttributes,changedTiddlers) { + // Reexecute the widget if the data reference attributes have changed + if(changedAttributes.data || changedTiddlers[this.data]) { + // Regenerate and rerender the widget and replace the existing DOM node + this.generate(); + var oldDomNode = this.renderer.domNode, + newDomNode = this.renderer.renderInDom(); + oldDomNode.parentNode.replaceChild(newDomNode,oldDomNode); + } else if(changedAttributes.grouped) { + // Update the chart if the grouping setting has changed + this.grouped = this.renderer.getAttribute("grouped","no"); + if(this.updateChart) { + this.updateChart(); + } + } +}; + +CloudWidget.prototype.createChart = function() { + + var domNode = this.renderer.domNode; + + var fill = d3.scale.category20(); + + d3.layout.cloud().size([300, 300]) + .words( + "As Apple patiently iterates its hardware design for the iPhone, we can extrapolate where they are heading And it looks awfully likely that the Platonic ideal of the iPhone is a slab of black glass for us to touch and look at with a disembodied voice to talk to No ports or grilles, just an array of radios printed onto the glass".split(" ").map(function(d) { + return {text: d, size: 10 + Math.random() * 90}; + })) + .padding(5) + .rotate(function() { return ~~(Math.random() * 2) * 90; }) + .font("Impact") + .fontSize(function(d) { return d.size; }) + .on("end", draw) + .start(); + + function draw(words) { + d3.select(domNode).append("svg") + .attr("width", 300) + .attr("height", 300) + .append("g") + .attr("transform", "translate(150,150)") + .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; }); + } + +}; + +exports.d3cloud = CloudWidget; + +})(); diff --git a/plugins/tiddlywiki/d3/files/LICENSE b/plugins/tiddlywiki/d3/files/LICENSE new file mode 100644 index 000000000..0bc47f33e --- /dev/null +++ b/plugins/tiddlywiki/d3/files/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2013, Michael Bostock +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* The name Michael Bostock may not be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL MICHAEL BOSTOCK BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/plugins/tiddlywiki/d3/files/cloud/LICENSE b/plugins/tiddlywiki/d3/files/cloud/LICENSE new file mode 100644 index 000000000..36ea51272 --- /dev/null +++ b/plugins/tiddlywiki/d3/files/cloud/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2013, Jason Davies. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * The name Jason Davies may not be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL JASON DAVIES BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/plugins/tiddlywiki/d3/files/cloud/d3.layout.cloud.js b/plugins/tiddlywiki/d3/files/cloud/d3.layout.cloud.js new file mode 100644 index 000000000..3fb84762d --- /dev/null +++ b/plugins/tiddlywiki/d3/files/cloud/d3.layout.cloud.js @@ -0,0 +1,401 @@ +// Word cloud layout by Jason Davies, http://www.jasondavies.com/word-cloud/ +// Algorithm due to Jonathan Feinberg, http://static.mrfeinberg.com/bv_ch03.pdf +(function(exports) { + function cloud() { + var size = [256, 256], + text = cloudText, + font = cloudFont, + fontSize = cloudFontSize, + fontStyle = cloudFontNormal, + fontWeight = cloudFontNormal, + rotate = cloudRotate, + padding = cloudPadding, + spiral = archimedeanSpiral, + words = [], + timeInterval = Infinity, + event = d3.dispatch("word", "end"), + timer = null, + cloud = {}; + + cloud.start = function() { + var 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 = +new Date, + d; + while (+new Date - start < timeInterval && ++i < n && timer) { + d = data[i]; + d.x = (size[0] * (Math.random() + .5)) >> 1; + d.y = (size[1] * (Math.random() + .5)) >> 1; + cloudSprite(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; + }; + + cloud.timeInterval = function(x) { + if (!arguments.length) return timeInterval; + timeInterval = x == null ? Infinity : x; + return cloud; + }; + + 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 = Math.random() < .5 ? 1 : -1, + t = -dt, + dxdy, + dx, + dy; + + while (dxdy = s(t += dt)) { + dx = ~~dxdy[0]; + dy = ~~dxdy[1]; + + if (Math.min(dx, 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.words = function(x) { + if (!arguments.length) return words; + words = x; + return cloud; + }; + + cloud.size = function(x) { + if (!arguments.length) return size; + size = [+x[0], +x[1]]; + return cloud; + }; + + cloud.font = function(x) { + if (!arguments.length) return font; + font = d3.functor(x); + return cloud; + }; + + cloud.fontStyle = function(x) { + if (!arguments.length) return fontStyle; + fontStyle = d3.functor(x); + return cloud; + }; + + cloud.fontWeight = function(x) { + if (!arguments.length) return fontWeight; + fontWeight = d3.functor(x); + return cloud; + }; + + cloud.rotate = function(x) { + if (!arguments.length) return rotate; + rotate = d3.functor(x); + return cloud; + }; + + cloud.text = function(x) { + if (!arguments.length) return text; + text = d3.functor(x); + return cloud; + }; + + cloud.spiral = function(x) { + if (!arguments.length) return spiral; + spiral = spirals[x + ""] || x; + return cloud; + }; + + cloud.fontSize = function(x) { + if (!arguments.length) return fontSize; + fontSize = d3.functor(x); + return cloud; + }; + + cloud.padding = function(x) { + if (!arguments.length) return padding; + padding = d3.functor(x); + return cloud; + }; + + return d3.rebind(cloud, event, "on"); + } + + 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(d, data, di) { + if (d.sprite) return; + 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; + } + + var cloudRadians = Math.PI / 180, + cw = 1 << 11 >> 5, + ch = 1 << 11, + canvas, + ratio = 1; + + if (typeof document !== "undefined") { + canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + ratio = Math.sqrt(canvas.getContext("2d").getImageData(0, 0, 1, 1).data.length >> 2); + canvas.width = (cw << 5) / ratio; + canvas.height = ch / ratio; + } else { + // node-canvas support + var Canvas = require("canvas"); + canvas = new Canvas(cw << 5, ch); + } + + var c = canvas.getContext("2d"), + spirals = { + archimedean: archimedeanSpiral, + rectangular: rectangularSpiral + }; + c.fillStyle = c.strokeStyle = "red"; + c.textAlign = "center"; + + exports.cloud = cloud; +})(typeof exports === "undefined" ? d3.layout || (d3.layout = {}) : exports); diff --git a/plugins/tiddlywiki/d3/files/tiddlywiki.files b/plugins/tiddlywiki/d3/files/tiddlywiki.files index 152c3f615..ec02f44ce 100644 --- a/plugins/tiddlywiki/d3/files/tiddlywiki.files +++ b/plugins/tiddlywiki/d3/files/tiddlywiki.files @@ -9,6 +9,14 @@ }, "prefix": "var d3;if($tw.browser){\n", "suffix": "}\nexports.d3 = d3;\n" + }, + { + "file": "cloud/d3.layout.cloud.js", + "fields": { + "type": "application/javascript", + "title": "$:/plugins/tiddlywiki/d3/d3.layout.cloud.js", + "module-type": "library" + } } ] }