UNPKG

@visactor/vgrammar-wordcloud

Version:

WordCloud layout transform for VGrammar

321 lines (307 loc) 16.6 kB
import { vglobal } from "@visactor/vrender-core"; import { isString, merge } from "@visactor/vutils"; import { getMaxRadiusAndCenter } from "@visactor/vgrammar-util"; import { BaseLayout } from "./base"; import { spirals } from "./spirals"; import { functor } from "./util"; const MAX_ARGUMENTS_LENGTH = 6e4; export class CloudLayout extends BaseLayout { constructor(options) { var _a; super(merge({}, CloudLayout.defaultOptions, options)), this.cw = 64, this.ch = 2048, this._size = [ 256, 256 ], this._isBoardExpandCompleted = !1, this._placeStatus = 0, this._tTemp = null, this._dtTemp = null, this._dy = 0, this.cacheMap = new Map, this.options.minFontSize <= CloudLayout.defaultOptions.minFontSize && (this.options.minFontSize = CloudLayout.defaultOptions.minFontSize), this.spiral = isString(this.options.spiral) ? null !== (_a = spirals[this.options.spiral]) && void 0 !== _a ? _a : spirals.archimedean : this.options.spiral, this.random = this.options.random ? Math.random : () => 1, this.getTextPadding = functor(this.options.padding); } zoomRatio() { return this._originSize[0] / this._size[0]; } dy() { return this._dy; } layoutWord(index) { const d = this.data[index]; if ("" === ("" + d.text).trim()) return !0; const {maxRadius: maxRadius, center: center} = getMaxRadiusAndCenter(this.options.shape, this._size); if (d.x = center[0], d.y = center[1], cloudSprite(this.contextAndRatio, d, this.data, index, this.cw, this.ch), this._placeStatus = 0, d.hasText && this.place(this._board, d, this._bounds, maxRadius)) return this.result.push(d), this._bounds ? cloudBounds(this._bounds, d) : this._bounds = [ { x: d.x + d.x0, y: d.y + d.y0 }, { x: d.x + d.x1, y: d.y + d.y1 } ], d.x -= this._size[0] >> 1, d.y -= this._size[1] >> 1, this._tTemp = null, this._dtTemp = null, !0; if (this.updateBoardExpandStatus(d.fontSize), d.hasText && this.shouldShrinkContinue()) { if (1 === this._placeStatus) { const maxSize0 = d.fontSize * this._originSize[0] / this.options.minFontSize, distSize0 = Math.max(d.width, d.height); if (distSize0 <= maxSize0) this._board = this.expandBoard(this._board, this._bounds, distSize0 / this._size[0]); else { if (!this.options.clip) return !0; this._board = this.expandBoard(this._board, this._bounds, maxSize0 / this._size[0]); } } else this._placeStatus, this._board = this.expandBoard(this._board, this._bounds); return this.updateBoardExpandStatus(d.fontSize), !1; } return this._tTemp = null, this._dtTemp = null, !0; } layout(words, config) { this.initProgressive(), this.result = [], this._size = [ config.width, config.height ], this.clearCache(), this._originSize = [ ...this._size ]; const contextAndRatio = this.getContext(vglobal.createCanvas({ width: 1, height: 1 })); this.contextAndRatio = contextAndRatio, this._board = new Uint32Array((this._size[0] >> 5) * this._size[1]).fill(0), this._bounds = null; words.length; this.result = []; const data = words.map(((d, i) => ({ text: this.getText(d), fontFamily: this.getTextFontFamily(d), fontStyle: this.getTextFontStyle(d), fontWeight: this.getTextFontWeight(d), angle: this.getTextRotate(d, i), fontSize: ~~this.getTextFontSize(d), padding: this.getTextPadding(d), xoff: 0, yoff: 0, x1: 0, y1: 0, x0: 0, y0: 0, hasText: !1, sprite: null, datum: d, x: 0, y: 0, width: 0, height: 0 }))).sort((function(a, b) { return b.fontSize - a.fontSize; })); if (this.originalData = data, this.data = data, this.progressiveRun(), !this.options.clip && this.options.enlarge && this._bounds && this.shrinkBoard(this._bounds), this._bounds && [ "cardioid", "triangle", "triangle-upright" ].includes(this.options.shape)) { const currentCenterY = (this._bounds[0].y + this._bounds[1].y) / 2; this._dy = -(currentCenterY - this._originSize[1] / 2); } return this.result; } formatTagItem(words) { const size = this._size, zoomRatio = this.zoomRatio(), globalDy = this.dy(), dx = size[0] >> 1, dy = size[1] >> 1, n = words.length, result = []; let w, t; for (let i = 0; i < n; ++i) w = words[i], t = {}, t.datum = w.datum, t.x = (w.x + dx) * zoomRatio, t.y = (w.y + dy + globalDy) * zoomRatio, t.fontFamily = w.fontFamily, t.fontSize = w.fontSize * zoomRatio, t.fontStyle = w.fontStyle, t.fontWeight = w.fontWeight, t.angle = w.angle, result.push(t); return result; } output() { return this.outputCallback ? this.outputCallback(this.formatTagItem(this.result)) : this.formatTagItem(this.result); } progressiveOutput() { return this.outputCallback ? this.outputCallback(this.formatTagItem(this.progressiveResult)) : this.formatTagItem(this.progressiveResult); } updateBoardExpandStatus(fontSize) { this._isBoardExpandCompleted = fontSize * (this._originSize[0] / this._size[0]) < this.options.minFontSize; } shouldShrinkContinue() { return !this.options.clip && this.options.shrink && !this._isBoardExpandCompleted; } shrinkBoard(bounds) { const leftTopPoint = bounds[0], rightBottomPoint = bounds[1]; if (rightBottomPoint.x >= this._size[0] || rightBottomPoint.y >= this._size[1]) return; const minXValue = Math.min(leftTopPoint.x, this._size[0] - rightBottomPoint.x), minYValue = Math.min(leftTopPoint.y, this._size[1] - rightBottomPoint.y), minRatio = 2 * Math.min(minXValue / this._size[0], minYValue / this._size[1]); this._size = this._size.map((v => v * (1 - minRatio))); } expandBoard(board, bounds, factor) { const oldW = this._size[0], oldH = this._size[1], oldRowStride = oldW >> 5, expandedLeftWidth = oldW * (factor || 1.1) - oldW >> 5; let diffWidth = 2 * expandedLeftWidth > 2 ? expandedLeftWidth : 2; diffWidth % 2 != 0 && diffWidth++; let diffHeight = Math.ceil(oldH * (diffWidth << 5) / oldW); diffHeight % 2 != 0 && diffHeight++; const newW = oldW + (diffWidth << 5), newH = oldH + diffHeight, newRowStride = newW >> 5, paddingLeft = diffWidth / 2, paddingTop = diffHeight / 2, newBoard = new Uint32Array(newH * newRowStride).fill(0); for (let y = 0; y < oldH; y++) { const sourceStartIndex = y * oldRowStride, sourceEndIndex = sourceStartIndex + oldRowStride, destStartIndex = (y + paddingTop) * newRowStride + paddingLeft, rowData = board.slice(sourceStartIndex, sourceEndIndex); newBoard.set(rowData, destStartIndex); } if (this._size = [ newW, newH ], bounds) { const offsetX = (diffWidth << 5) / 2, offsetY = diffHeight / 2; bounds[0].x += offsetX, bounds[0].y += offsetY, bounds[1].x += offsetX, bounds[1].y += offsetY; } return newBoard; } insertZerosToArray(array, index, length) { if (this.options.customInsertZerosToArray) return this.options.customInsertZerosToArray(array, index, length); const len = Math.floor(length / 6e4), restLen = length % 6e4; for (let i = 0; i < len; i++) array.splice(index + 6e4 * i, 0, ...new Array(6e4).fill(0)); array.splice(index + 6e4 * len, 0, ...new Array(restLen).fill(0)); } getContext(canvas) { canvas.width = 1, canvas.height = 1; const imageData = canvas.getContext("2d", { willReadFrequently: !0 }).getImageData(0, 0, 1, 1), ratio = Math.sqrt(imageData.data.length >> 2); canvas.width = (this.cw << 5) / ratio, canvas.height = this.ch / ratio; const context = canvas.getContext("2d", { willReadFrequently: !0 }); return context.fillStyle = context.strokeStyle = "red", context.textAlign = "center", { context: context, ratio: ratio, canvas: canvas }; } place(board, tag, bounds, maxRadius) { let isCollide = !1; if (this.shouldShrinkContinue() && (tag.width > this._size[0] || tag.height > this._size[1])) return this._placeStatus = 1, !1; const dt = this.random() < .5 ? 1 : -1; if (!this.shouldShrinkContinue() && this.isSizeLargerThanMax(tag, dt)) return null; const startX = tag.x, startY = tag.y, maxDelta = Math.sqrt(this._size[0] * this._size[0] + this._size[1] * this._size[1]), s = this.spiral(this._size); let dxdy, dx, dy, _tag, t = -dt; for (this._tTemp = null, this._dtTemp = null; dxdy = s(t += dt); ) { dx = dxdy[0], dy = dxdy[1]; const radius = Math.sqrt(dx ** 2 + dy ** 2); let rad = Math.atan(dy / dx); dx < 0 ? rad += Math.PI : dy < 0 && (rad = 2 * Math.PI + rad); const rx = this.shape(rad); if (Math.min(Math.abs(dx), Math.abs(dy)) >= maxDelta) break; if (radius >= maxRadius) isCollide && null === this._tTemp && (this._tTemp = t, this._dtTemp = dt); else { if (tag.x = startX + ~~(radius * rx * Math.cos(-rad)), tag.y = startY + ~~(radius * rx * Math.sin(-rad)), _tag = tag, this.options.clip) if (this.shouldShrinkContinue()) { if (isPartOutside(_tag, this._size)) { isCollide && null === this._tTemp && (this._tTemp = t, this._dtTemp = dt); continue; } } else { if (isFullOutside(_tag, this._size)) { isCollide && null === this._tTemp && (this._tTemp = t, this._dtTemp = dt); continue; } isPartOutside(_tag, this._size) && (_tag = clipInnerTag(_tag, this._size)); } else if (isPartOutside(_tag, this._size)) { isCollide && null === this._tTemp && (this._tTemp = t, this._dtTemp = dt); continue; } if (isCollide = !0, (!bounds || collideRects(_tag, bounds)) && (!bounds || !cloudCollide(_tag, board, this._size))) { const sprite = _tag.sprite, w = _tag.width >> 5, sw = this._size[0] >> 5, lx = _tag.x - (w << 4), sx = 127 & lx, msx = 32 - sx, h = _tag.y1 - _tag.y0; let last, x = (_tag.y + _tag.y0) * sw + (lx >> 5); for (let j = 0; j < h; j++) { last = 0; for (let i = 0; i <= w; i++) board[x + i] |= last << msx | (i < w ? (last = sprite[j * w + i]) >>> sx : 0); x += sw; } return tag.sprite = null, _tag.sprite = null, !0; } } } return null !== this._tTemp && (this._placeStatus = 3), !this.shouldShrinkContinue() && this.setCache(_tag, dt), !1; } clearCache() { this.cacheMap.clear(); } setCache(tag, dt) { const cacheKey = `${tag.angle}-${dt}`, w = tag.x1 - tag.x0, h = tag.y1 - tag.y0; if (!this.cacheMap.has(cacheKey)) return void this.cacheMap.set(cacheKey, { width: w, height: h }); const {width: width, height: height} = this.cacheMap.get(cacheKey); (w < width && h < height || w <= width && h < height) && this.cacheMap.set(cacheKey, { width: w, height: h }); } isSizeLargerThanMax(tag, dt) { const cacheKey = `${tag.angle}-${dt}`; if (!this.cacheMap.has(cacheKey)) return !1; const {width: width, height: height} = this.cacheMap.get(cacheKey), w = tag.x1 - tag.x0, h = tag.y1 - tag.y0; return w >= width && h >= height; } } function cloudSprite(contextAndRatio, d, data, di, cw, ch) { if (d.sprite) return; const c = contextAndRatio.context, ratio = contextAndRatio.ratio; c.setTransform(ratio, 0, 0, ratio, 0, 0), c.clearRect(0, 0, (cw << 5) / ratio, ch / ratio); let x = 0, y = 0, maxh = 0; const n = data.length; let w, w32, h, i, j; for (--di; ++di < n; ) { if (d = data[di], c.save(), c.font = d.fontStyle + " " + d.fontWeight + " " + ~~((d.fontSize + 1) / ratio) + "px " + d.fontFamily, w = c.measureText(d.text + "m").width * ratio, h = d.fontSize << 1, d.angle) { const sr = Math.sin(d.angle), cr = Math.cos(d.angle), wcr = w * cr, wsr = w * sr, hcr = h * cr, hsr = h * sr; w = Math.max(Math.abs(wcr + hsr), Math.abs(wcr - hsr)) + 31 >> 5 << 5, h = ~~Math.max(Math.abs(wsr + hcr), Math.abs(wsr - hcr)); } else w = w + 31 >> 5 << 5; if (h > maxh && (maxh = h), x + w >= cw << 5 && (x = 0, y += maxh, maxh = 0), y + h >= ch) break; c.translate((x + (w >> 1)) / ratio, (y + (h >> 1)) / ratio), d.angle && c.rotate(d.angle), c.fillText(d.text, 0, 0), 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 = !0, x += w; } const pixels = c.getImageData(0, 0, (cw << 5) / ratio, ch / ratio).data, sprite = []; for (;--di >= 0; ) { if (!(d = data[di]).hasText) continue; for (w = d.width, w32 = w >> 5, h = d.y1 - d.y0, i = 0; i < h * w32; i++) sprite[i] = 0; if (x = d.xoff, null == x) return; y = d.yoff; let seen = 0, seenRow = -1; for (j = 0; j < h; j++) { for (i = 0; i < w; i++) { const k = w32 * j + (i >> 5), m = pixels[(y + j) * (cw << 5) + (x + i) << 2] ? 1 << 31 - i % 32 : 0; sprite[k] |= m, seen |= m; } seen ? seenRow = j : (d.y0++, h--, j--, y++); } d.y1 = d.y0 + seenRow, d.sprite = sprite.slice(0, (d.y1 - d.y0) * w32); } } function cloudCollide(tag, board, size) { const sw = size[0] >> 5, sprite = tag.sprite, w = tag.width >> 5, lx = tag.x - (w << 4), sx = 127 & lx, msx = 32 - sx, h = tag.y1 - tag.y0; let last, x = (tag.y + tag.y0) * sw + (lx >> 5); for (let j = 0; j < h; j++) { last = 0; for (let i = 0; i <= w; i++) if ((last << msx | (i < w ? (last = sprite[j * w + i]) >>> sx : 0)) & board[x + i]) return !0; x += sw; } return !1; } function cloudBounds(bounds, d) { const b0 = bounds[0], b1 = bounds[1]; d.x + d.x0 < b0.x && (b0.x = d.x + d.x0), d.y + d.y0 < b0.y && (b0.y = d.y + d.y0), d.x + d.x1 > b1.x && (b1.x = d.x + d.x1), 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; } CloudLayout.defaultOptions = { enlarge: !1, minFontSize: 2, maxSingleWordTryCount: 2 }; const isFullOutside = (tag, size) => tag.x + tag.x0 > size[0] || tag.y + tag.y0 > size[0] || tag.x + tag.x1 < 0 || tag.y + tag.y1 < 0, isPartOutside = (tag, size) => tag.x + tag.x0 < 0 || tag.y + tag.y0 < 0 || tag.x + tag.x1 > size[0] || tag.y + tag.y1 > size[1]; function clipInnerTag(tag, size) { const sprite = tag.sprite, h = tag.y1 - tag.y0, w = tag.width >> 5; let x = 0; const _sprite = [], js = Math.max(-(tag.y0 + tag.y), 0), je = Math.min(h + (size[1] - (tag.y1 + tag.y)), h), is = Math.max(-(tag.x0 + tag.x), 0) >> 5, ie = Math.min(w + (size[0] - (tag.x1 + tag.x) >> 5) + 1, w); for (let j = 0; j < h; j++) { for (let i = 0; i < w; i++) j < js || je <= j || i < is || ie <= i || _sprite.push(sprite[x + i]); x += w; } const xl = is << 5, xr = w - ie << 5, yb = js, yt = h - je; return Object.assign(Object.assign({}, tag), { width: tag.width - xl - xr, height: tag.height - yb - yt, x0: tag.x0 + xl, x1: tag.x1 - xr, y0: tag.y0 + yb, y1: tag.y1 - yt, x: tag.x + xl / 2 - xr / 2, sprite: _sprite }); } //# sourceMappingURL=cloud-layout.js.map