word-cloud.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. import { assign, isFunction, isNil } from '@antv/util';
  2. var DEFAULT_OPTIONS = {
  3. font: function () { return 'serif'; },
  4. padding: 1,
  5. size: [500, 500],
  6. spiral: 'archimedean',
  7. // timeInterval: Infinity // max execute time
  8. timeInterval: 3000, // max execute time
  9. // imageMask: '', // instance of Image, must be loaded
  10. };
  11. /**
  12. * 根据对应的数据对象,计算每个
  13. * 词语在画布中的渲染位置,并返回
  14. * 计算后的数据对象
  15. * @param words
  16. * @param options
  17. */
  18. export function wordCloud(words, options) {
  19. // 混入默认配置
  20. options = assign({}, DEFAULT_OPTIONS, options);
  21. return transform(words, options);
  22. }
  23. /**
  24. * 抛出没有混入默认配置的方法,用于测试。
  25. * @param words
  26. * @param options
  27. */
  28. export function transform(words, options) {
  29. // 布局对象
  30. var layout = tagCloud();
  31. ['font', 'fontSize', 'fontWeight', 'padding', 'rotate', 'size', 'spiral', 'timeInterval', 'random'].forEach(function (key) {
  32. if (!isNil(options[key])) {
  33. layout[key](options[key]);
  34. }
  35. });
  36. layout.words(words);
  37. if (options.imageMask) {
  38. layout.createMask(options.imageMask);
  39. }
  40. var result = layout.start();
  41. var tags = result._tags;
  42. tags.forEach(function (tag) {
  43. tag.x += options.size[0] / 2;
  44. tag.y += options.size[1] / 2;
  45. });
  46. var _a = options.size, w = _a[0], h = _a[1];
  47. // 添加两个参照数据,分别表示左上角和右下角。
  48. // 不添加的话不会按照真实的坐标渲染,而是以
  49. // 数据中的边界坐标为边界进行拉伸,以铺满画布。
  50. // 这样的后果会导致词语之间的重叠。
  51. tags.push({
  52. text: '',
  53. value: 0,
  54. x: 0,
  55. y: 0,
  56. opacity: 0,
  57. });
  58. tags.push({
  59. text: '',
  60. value: 0,
  61. x: w,
  62. y: h,
  63. opacity: 0,
  64. });
  65. return tags;
  66. }
  67. var cloudRadians = Math.PI / 180, cw = (1 << 11) >> 5, ch = 1 << 11;
  68. function cloudText(d) {
  69. return d.text;
  70. }
  71. function cloudFont() {
  72. return 'serif';
  73. }
  74. function cloudFontNormal() {
  75. return 'normal';
  76. }
  77. function cloudFontSize(d) {
  78. return d.value;
  79. }
  80. function cloudRotate() {
  81. return ~~(Math.random() * 2) * 90;
  82. }
  83. function cloudPadding() {
  84. return 1;
  85. }
  86. // Fetches a monochrome sprite bitmap for the specified text.
  87. // Load in batches for speed.
  88. function cloudSprite(contextAndRatio, d, data, di) {
  89. if (d.sprite)
  90. return;
  91. var c = contextAndRatio.context, ratio = contextAndRatio.ratio;
  92. c.clearRect(0, 0, (cw << 5) / ratio, ch / ratio);
  93. var x = 0, y = 0, maxh = 0;
  94. var n = data.length;
  95. --di;
  96. while (++di < n) {
  97. d = data[di];
  98. c.save();
  99. c.font = d.style + ' ' + d.weight + ' ' + ~~((d.size + 1) / ratio) + 'px ' + d.font;
  100. var w = c.measureText(d.text + 'm').width * ratio, h = d.size << 1;
  101. if (d.rotate) {
  102. 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;
  103. w = ((Math.max(Math.abs(wcr + hsr), Math.abs(wcr - hsr)) + 0x1f) >> 5) << 5;
  104. h = ~~Math.max(Math.abs(wsr + hcr), Math.abs(wsr - hcr));
  105. }
  106. else {
  107. w = ((w + 0x1f) >> 5) << 5;
  108. }
  109. if (h > maxh)
  110. maxh = h;
  111. if (x + w >= cw << 5) {
  112. x = 0;
  113. y += maxh;
  114. maxh = 0;
  115. }
  116. if (y + h >= ch)
  117. break;
  118. c.translate((x + (w >> 1)) / ratio, (y + (h >> 1)) / ratio);
  119. if (d.rotate)
  120. c.rotate(d.rotate * cloudRadians);
  121. c.fillText(d.text, 0, 0);
  122. if (d.padding) {
  123. c.lineWidth = 2 * d.padding;
  124. c.strokeText(d.text, 0, 0);
  125. }
  126. c.restore();
  127. d.width = w;
  128. d.height = h;
  129. d.xoff = x;
  130. d.yoff = y;
  131. d.x1 = w >> 1;
  132. d.y1 = h >> 1;
  133. d.x0 = -d.x1;
  134. d.y0 = -d.y1;
  135. d.hasText = true;
  136. x += w;
  137. }
  138. var pixels = c.getImageData(0, 0, (cw << 5) / ratio, ch / ratio).data, sprite = [];
  139. while (--di >= 0) {
  140. d = data[di];
  141. if (!d.hasText)
  142. continue;
  143. var w = d.width, w32 = w >> 5;
  144. var h = d.y1 - d.y0;
  145. // Zero the buffer
  146. for (var i = 0; i < h * w32; i++)
  147. sprite[i] = 0;
  148. x = d.xoff;
  149. if (x == null)
  150. return;
  151. y = d.yoff;
  152. var seen = 0, seenRow = -1;
  153. for (var j = 0; j < h; j++) {
  154. for (var i = 0; i < w; i++) {
  155. var k = w32 * j + (i >> 5), m = pixels[((y + j) * (cw << 5) + (x + i)) << 2] ? 1 << (31 - (i % 32)) : 0;
  156. sprite[k] |= m;
  157. seen |= m;
  158. }
  159. if (seen)
  160. seenRow = j;
  161. else {
  162. d.y0++;
  163. h--;
  164. j--;
  165. y++;
  166. }
  167. }
  168. d.y1 = d.y0 + seenRow;
  169. d.sprite = sprite.slice(0, (d.y1 - d.y0) * w32);
  170. }
  171. }
  172. // Use mask-based collision detection.
  173. function cloudCollide(tag, board, sw) {
  174. sw >>= 5;
  175. var sprite = tag.sprite, w = tag.width >> 5, lx = tag.x - (w << 4), sx = lx & 0x7f, msx = 32 - sx, h = tag.y1 - tag.y0;
  176. var x = (tag.y + tag.y0) * sw + (lx >> 5), last;
  177. for (var j = 0; j < h; j++) {
  178. last = 0;
  179. for (var i = 0; i <= w; i++) {
  180. if (((last << msx) | (i < w ? (last = sprite[j * w + i]) >>> sx : 0)) & board[x + i])
  181. return true;
  182. }
  183. x += sw;
  184. }
  185. return false;
  186. }
  187. function cloudBounds(bounds, d) {
  188. var b0 = bounds[0], b1 = bounds[1];
  189. if (d.x + d.x0 < b0.x)
  190. b0.x = d.x + d.x0;
  191. if (d.y + d.y0 < b0.y)
  192. b0.y = d.y + d.y0;
  193. if (d.x + d.x1 > b1.x)
  194. b1.x = d.x + d.x1;
  195. if (d.y + d.y1 > b1.y)
  196. b1.y = d.y + d.y1;
  197. }
  198. function collideRects(a, b) {
  199. 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;
  200. }
  201. function archimedeanSpiral(size) {
  202. var e = size[0] / size[1];
  203. return function (t) {
  204. return [e * (t *= 0.1) * Math.cos(t), t * Math.sin(t)];
  205. };
  206. }
  207. function rectangularSpiral(size) {
  208. var dy = 4, dx = (dy * size[0]) / size[1];
  209. var x = 0, y = 0;
  210. return function (t) {
  211. var sign = t < 0 ? -1 : 1;
  212. // See triangular numbers: T_n = n * (n + 1) / 2.
  213. switch ((Math.sqrt(1 + 4 * sign * t) - sign) & 3) {
  214. case 0:
  215. x += dx;
  216. break;
  217. case 1:
  218. y += dy;
  219. break;
  220. case 2:
  221. x -= dx;
  222. break;
  223. default:
  224. y -= dy;
  225. break;
  226. }
  227. return [x, y];
  228. };
  229. }
  230. // TODO reuse arrays?
  231. function zeroArray(n) {
  232. var a = [];
  233. var i = -1;
  234. while (++i < n)
  235. a[i] = 0;
  236. return a;
  237. }
  238. function cloudCanvas() {
  239. return document.createElement('canvas');
  240. }
  241. export function functor(d) {
  242. return isFunction(d)
  243. ? d
  244. : function () {
  245. return d;
  246. };
  247. }
  248. var spirals = {
  249. archimedean: archimedeanSpiral,
  250. rectangular: rectangularSpiral,
  251. };
  252. function tagCloud() {
  253. var size = [256, 256], font = cloudFont, fontSize = cloudFontSize, fontWeight = cloudFontNormal, rotate = cloudRotate, padding = cloudPadding, spiral = archimedeanSpiral, random = Math.random, words = [], timeInterval = Infinity;
  254. var text = cloudText;
  255. var fontStyle = cloudFontNormal;
  256. var canvas = cloudCanvas;
  257. var cloud = {};
  258. cloud.start = function () {
  259. var width = size[0], height = size[1];
  260. var contextAndRatio = getContext(canvas()), board = cloud.board ? cloud.board : zeroArray((size[0] >> 5) * size[1]), n = words.length, tags = [], data = words
  261. .map(function (d, i, data) {
  262. d.text = text.call(this, d, i, data);
  263. d.font = font.call(this, d, i, data);
  264. d.style = fontStyle.call(this, d, i, data);
  265. d.weight = fontWeight.call(this, d, i, data);
  266. d.rotate = rotate.call(this, d, i, data);
  267. d.size = ~~fontSize.call(this, d, i, data);
  268. d.padding = padding.call(this, d, i, data);
  269. return d;
  270. })
  271. .sort(function (a, b) {
  272. return b.size - a.size;
  273. });
  274. var i = -1, bounds = !cloud.board
  275. ? null
  276. : [
  277. {
  278. x: 0,
  279. y: 0,
  280. },
  281. {
  282. x: width,
  283. y: height,
  284. },
  285. ];
  286. step();
  287. function step() {
  288. var start = Date.now();
  289. while (Date.now() - start < timeInterval && ++i < n) {
  290. var d = data[i];
  291. d.x = (width * (random() + 0.5)) >> 1;
  292. d.y = (height * (random() + 0.5)) >> 1;
  293. cloudSprite(contextAndRatio, d, data, i);
  294. if (d.hasText && place(board, d, bounds)) {
  295. tags.push(d);
  296. if (bounds) {
  297. if (!cloud.hasImage) {
  298. // update bounds if image mask not set
  299. cloudBounds(bounds, d);
  300. }
  301. }
  302. else {
  303. bounds = [
  304. { x: d.x + d.x0, y: d.y + d.y0 },
  305. { x: d.x + d.x1, y: d.y + d.y1 },
  306. ];
  307. }
  308. // Temporary hack
  309. d.x -= size[0] >> 1;
  310. d.y -= size[1] >> 1;
  311. }
  312. }
  313. cloud._tags = tags;
  314. cloud._bounds = bounds;
  315. }
  316. return cloud;
  317. };
  318. function getContext(canvas) {
  319. canvas.width = canvas.height = 1;
  320. var ratio = Math.sqrt(canvas.getContext('2d', { willReadFrequently: true }).getImageData(0, 0, 1, 1).data
  321. .length >> 2);
  322. canvas.width = (cw << 5) / ratio;
  323. canvas.height = ch / ratio;
  324. var context = canvas.getContext('2d', { willReadFrequently: true });
  325. context.fillStyle = context.strokeStyle = 'red';
  326. context.textAlign = 'center';
  327. return { context: context, ratio: ratio };
  328. }
  329. function place(board, tag, bounds) {
  330. // const perimeter = [{ x: 0, y: 0 }, { x: size[0], y: size[1] }],
  331. var startX = tag.x, startY = tag.y, maxDelta = Math.sqrt(size[0] * size[0] + size[1] * size[1]), s = spiral(size), dt = random() < 0.5 ? 1 : -1;
  332. var dxdy, t = -dt, dx, dy;
  333. while ((dxdy = s((t += dt)))) {
  334. dx = ~~dxdy[0];
  335. dy = ~~dxdy[1];
  336. if (Math.min(Math.abs(dx), Math.abs(dy)) >= maxDelta)
  337. break;
  338. tag.x = startX + dx;
  339. tag.y = startY + dy;
  340. if (tag.x + tag.x0 < 0 || tag.y + tag.y0 < 0 || tag.x + tag.x1 > size[0] || tag.y + tag.y1 > size[1])
  341. continue;
  342. // TODO only check for collisions within current bounds.
  343. if (!bounds || !cloudCollide(tag, board, size[0])) {
  344. if (!bounds || collideRects(tag, bounds)) {
  345. 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;
  346. var last = void 0, x = (tag.y + tag.y0) * sw + (lx >> 5);
  347. for (var j = 0; j < h; j++) {
  348. last = 0;
  349. for (var i = 0; i <= w; i++) {
  350. board[x + i] |= (last << msx) | (i < w ? (last = sprite[j * w + i]) >>> sx : 0);
  351. }
  352. x += sw;
  353. }
  354. delete tag.sprite;
  355. return true;
  356. }
  357. }
  358. }
  359. return false;
  360. }
  361. cloud.createMask = function (img) {
  362. var can = document.createElement('canvas');
  363. var width = size[0], height = size[1];
  364. // 当 width 或 height 为 0 时,调用 cxt.getImageData 会报错
  365. if (!width || !height) {
  366. return;
  367. }
  368. var w32 = width >> 5;
  369. var board = zeroArray((width >> 5) * height);
  370. can.width = width;
  371. can.height = height;
  372. var cxt = can.getContext('2d');
  373. cxt.drawImage(img, 0, 0, img.width, img.height, 0, 0, width, height);
  374. var imageData = cxt.getImageData(0, 0, width, height).data;
  375. for (var j = 0; j < height; j++) {
  376. for (var i = 0; i < width; i++) {
  377. var k = w32 * j + (i >> 5);
  378. var tmp = (j * width + i) << 2;
  379. var flag = imageData[tmp] >= 250 && imageData[tmp + 1] >= 250 && imageData[tmp + 2] >= 250;
  380. var m = flag ? 1 << (31 - (i % 32)) : 0;
  381. board[k] |= m;
  382. }
  383. }
  384. cloud.board = board;
  385. cloud.hasImage = true;
  386. };
  387. cloud.timeInterval = function (_) {
  388. timeInterval = _ == null ? Infinity : _;
  389. };
  390. cloud.words = function (_) {
  391. words = _;
  392. };
  393. cloud.size = function (_) {
  394. size = [+_[0], +_[1]];
  395. };
  396. cloud.font = function (_) {
  397. font = functor(_);
  398. };
  399. cloud.fontWeight = function (_) {
  400. fontWeight = functor(_);
  401. };
  402. cloud.rotate = function (_) {
  403. rotate = functor(_);
  404. };
  405. cloud.spiral = function (_) {
  406. spiral = spirals[_] || _;
  407. };
  408. cloud.fontSize = function (_) {
  409. fontSize = functor(_);
  410. };
  411. cloud.padding = function (_) {
  412. padding = functor(_);
  413. };
  414. cloud.random = function (_) {
  415. random = functor(_);
  416. };
  417. return cloud;
  418. }
  419. //# sourceMappingURL=word-cloud.js.map