diff --git a/resources/public/js/extra.js b/resources/public/js/extra.js index e91195b..55499b6 100644 --- a/resources/public/js/extra.js +++ b/resources/public/js/extra.js @@ -4,13 +4,13 @@ // NOTE should be matched with externs in extra.externs.js // -var extra = (function () { +var extra = (function() { "use strict"; /** * Given a multiselect element, get array of selected values */ - var multiselect_values = function (el) { + var multiselect_values = function(el) { var values = []; for (var i = 0, n = el.options.length; i < n; i++) { @@ -27,14 +27,132 @@ var extra = (function () { /** * Given a select element, get selected value (or null) */ - var select_values = function (el) { + var select_values = function(el) { var mv = multiselect_values(el); return mv.length > 0 ? mv[0] : null; }; + /** + * Pack blocks into bin + * Adapted from: + * http://codeincomplete.com/posts/2011/5/7/bin_packing/ + */ + var Packer = (function() { + + Packer = function(w, h) { + this.init(w, h); + }; + + Packer.prototype = { + init: function(w, h) { + this.root = { x: 0, y: 0, w: w, h: h }; + }, + + fit: function(blocks) { + var n, node, block, final; + + final = []; + + for (n = 0; n < blocks.length; n++) { + block = blocks[n]; + + if (node = this.findNode(this.root, block.w, block.h)) { + block.fit = this.splitNode(node, block.w, block.h); + final.push(block); + } + } + + return final; + }, + + findNode: function(root, w, h) { + if (root.used) { + return this.findNode(root.right, w, h) || this.findNode(root.down, w, h); + } else if ((w <= root.w) && (h <= root.h)) { + return root; + } else { + return null; + } + }, + + splitNode: function(node, w, h) { + node.used = true; + node.down = { x: node.x, y: node.y + h, w: node.w, h: node.h - h }; + node.right = { x: node.x + w, y: node.y, w: node.w - w, h: h }; + return node; + } + }; + + return Packer; + })(); + + /** + * Get the dimensions of a piece of text + * textDimensions('hello world!', + * { fontFamily: 'Arial', fontSize: '16px', fontWeight: 'normal'}) + */ + var textDimensions = (function() { + var createDummyElement = function(name, options) { + var element = document.createElement('div'), + nameNode = document.createTextNode(name); + + element.appendChild(nameNode); + + element.style.fontFamily = options.fontFamily; + element.style.fontSize = options.fontSize; + element.style.fontWeight = options.fontWeight; + element.style.position = 'absolute'; + element.style.visibility = 'hidden'; + element.style.left = '-999px'; + element.style.top = '-999px'; + element.style.width = 'auto'; + element.style.height = 'auto'; + + document.body.appendChild(element); + + return element; + }; + + var destroyElement = function(element) { + element.parentNode.removeChild(element); + } + + var cache = {}; + + return function(name, options) { + var cacheKey = JSON.stringify({ name: name, options: options }); + + if (cache[cacheKey]) { + return cache[cacheKey]; + } + + // prepare options + options = options || {}; + options.fontFamily = options.fontFamily || 'Times'; + options.fontSize = options.fontSize || '16px'; + options.fontWeight = options.fontWeight || 'normal'; + + var element = createDummyElement(name, options); + + var result = { + w: element.offsetWidth, + h: element.offsetHeight, + name: name + }; + + destroyElement(element); + + cache[cacheKey] = result; + + return result; + }; + })(); + return { multiselect_values: multiselect_values, - select_values: select_values + select_values: select_values, + Packer: Packer, + textDimensions: textDimensions }; })(); diff --git a/src/cljs/ulysses/components/word_cloud.cljs b/src/cljs/ulysses/components/word_cloud.cljs index 0d55e1c..7bccf2b 100644 --- a/src/cljs/ulysses/components/word_cloud.cljs +++ b/src/cljs/ulysses/components/word_cloud.cljs @@ -1,5 +1,7 @@ (ns ulysses.components.word-cloud - (:require [ulysses.utils :refer [map-subels]])) + (:require [ulysses.utils :refer [map-subels]] + [ulysses.lib.packer :refer [fit-words]] + [clojure.string :as string])) ;; ---------------------------------------------------------------------------- ;; helpers @@ -12,7 +14,7 @@ denom (- s-max s-min)] (map (fn [kw] - (assoc kw :nscore + (assoc kw :weight (/ (- (get-in kw [:pivot :score]) s-min) denom))) keywords))) @@ -20,7 +22,7 @@ (defn normalize-sort-take [n keywords] (->> keywords (normalize) - (sort-by :nscore) + (sort-by :weight) (reverse) (take n))) @@ -28,8 +30,8 @@ ;; sub-components ;; ---------------------------------------------------------------------------- -(defn word-cloud-keyword [{:keys [name nscore]}] - [:a.keyword {:class (str "weight-" (Math/round (* 100 nscore)))} name]) +(defn word-cloud-keyword [{:keys [name weight]}] + [:a.keyword {:class (str "weight-" (Math/round (* 100 weight)))} name]) ;; ---------------------------------------------------------------------------- ;; main @@ -37,4 +39,5 @@ (defn word-cloud [n keywords] [:div.word-cloud - (map-subels word-cloud-keyword (normalize-sort-take n keywords))]) + [fit-words 900 500 + (normalize-sort-take n keywords)]]) diff --git a/src/cljs/ulysses/lib/packer.cljs b/src/cljs/ulysses/lib/packer.cljs new file mode 100644 index 0000000..d395bd5 --- /dev/null +++ b/src/cljs/ulysses/lib/packer.cljs @@ -0,0 +1,58 @@ +(ns ulysses.lib.packer + (:require [clojure.walk :refer [keywordize-keys]] + [ulysses.utils :refer [map-subels ptr]])) + +(def jsPacker (.-Packer js/extra)) + +(defn fit + [w h blocks] + (let [p (new jsPacker w h)] + (->> blocks + (sort-by :h) + (reverse) + (clj->js) + (.fit p) + (js->clj) + (keywordize-keys) + (map + (fn [block] + (update block :fit dissoc + :used :right :down :w :h)))))) + +(def jsTextDimensions (.-textDimensions js/extra)) + +(defn text-dimensions + [text options] + (-> text + (jsTextDimensions (clj->js options)) + (js->clj) + (keywordize-keys))) + +(defn fit-words + [w h words] + (let [style-base {:fontFamily "Source Sans Pro" :fontWeight "100"}] + [:div + {:style {:position :relative :width w :height h}} + (->> words + (map + (fn [{:keys [name weight]}] + (let [style (assoc style-base :fontSize (-> weight (* 10) (Math/log) (* 35) (str "px")))] + (-> name + (text-dimensions style) + (update :w + (partial + 8)) + (assoc :style style) + (assoc :weight weight))))) + (fit w h) + (map-subels + (fn [{:keys [w h fit name style weight]}] + [:div.keyword + {:style + (assoc style + :width w + :height h + :left (:x fit) + :top (:y fit)) + :class + (str "weight-" (Math/round (* 100 weight)))} + name])))])) diff --git a/src/cljs/ulysses/pages/builder.cljs b/src/cljs/ulysses/pages/builder.cljs index d87e8c4..4e5c386 100644 --- a/src/cljs/ulysses/pages/builder.cljs +++ b/src/cljs/ulysses/pages/builder.cljs @@ -11,6 +11,7 @@ [ulysses.components.misc :refer [grant-op-meta]] [ulysses.components.word-cloud :refer [word-cloud]] [ulysses.utils :refer [map-subels map-lookup str->int classes-attr]] + [ulysses.lib.packer :as packer] [reagent.core :as r] [clojure.string :as string] [cljs.pprint :as pprint])) diff --git a/src/externs/extra.externs.js b/src/externs/extra.externs.js index 04d6f57..790eb3f 100644 --- a/src/externs/extra.externs.js +++ b/src/externs/extra.externs.js @@ -5,14 +5,63 @@ */ var extra = {}; +// ---------------------------------------------------------------------------- +// Select values +// ---------------------------------------------------------------------------- + /** * @param {Element} el * @return {Array.} */ -extra.multiselect_values = function (el) {}; +extra.multiselect_values = function(el) {}; /** * @param {Element} el * @return {string|null} */ -extra.select_values = function (el) {}; +extra.select_values = function(el) {}; + +// ---------------------------------------------------------------------------- +// Packer +// ---------------------------------------------------------------------------- + +/** + * @param {Int} w + * @param {Int} h + */ +extra.Packer = function(w, h) {}; + +/** + * @param {Int} w + * @param {Int} h + */ +extra.Packer.prototype.init = function(w, h) {}; + +/** + * @param {Array} blocks + */ +extra.Packer.prototype.fit = function(blocks) {}; + +/** + * @param {Object} root + * @param {Int} w + * @param {Int} h + */ +extra.Packer.prototype.findNode = function(root, w, h) {}; + +/** + * @param {Object} node + * @param {Int} w + * @param {Int} h + */ +extra.Packer.prototype.splitNode = function(node, w, h) {}; + +// ---------------------------------------------------------------------------- +// Text dimensions +// ---------------------------------------------------------------------------- + +/** + * @param {string} text + * @param {Object} options + */ +extra.textDimensions = function(text, options) {}; diff --git a/src/sass/components/_word-cloud.scss b/src/sass/components/_word-cloud.scss index d8b56aa..f6f4b73 100644 --- a/src/sass/components/_word-cloud.scss +++ b/src/sass/components/_word-cloud.scss @@ -7,10 +7,11 @@ background: $black; color: $white; display: block; - float: left; - margin: .5em .5em 0 0; - padding: .3em .7em .2em; + overflow: hidden; + padding: 0 4px; + position: absolute; text-decoration: none; + white-space: nowrap; @for $i from 0 through 100 { &.weight-#{$i} {