UNPKG

tag2cloud

Version:
649 lines (572 loc) 19.5 kB
export interface Options { width: number; height: number; maskImage: string | false | null | undefined; pixelRatio: number; lightThreshold: number; opacityThreshold: number; minFontSize: number; maxFontSize: number; angleFrom: number; angleTo: number; angleCount: number; family: string; cut: boolean; padding: number; canvas: boolean; shape: ((theta: number) => number) | null; } export interface Tag { text: string; weight: number; angle?: number; color?: string; [prop: string]: any; } export interface Pixels { width: number; height: number; data: number[][]; } export interface TagData extends Required<Tag> { angle: number; fontSize: number; x: number; y: number; rendered: boolean; tag: Tag; } const ZERO_STR = "00000000000000000000000000000000"; const TIMEOUT_MS = 100; export class Tag2Cloud { private readonly defaultOptions: Options = { width: 200, height: 200, maskImage: false, pixelRatio: 4, lightThreshold: ((255 * 3) / 2) >> 0, opacityThreshold: 255, minFontSize: 10, maxFontSize: 100, angleFrom: -60, angleTo: 60, angleCount: 3, family: "sans-serif", cut: false, padding: 5, canvas: false, shape: null }; options: Options; private $container: HTMLElement; private $wrapper: HTMLElement; private $canvas: HTMLCanvasElement; private $displayCanvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; private displayCtx: CanvasRenderingContext2D; private listeners: Function[] = []; private pixels: Pixels = { width: 0, height: 0, data: [] }; private maxTagWeight = 0; private minTagWeight = Infinity; private promised: Promise<void> = Promise.resolve(); private points: number[] = []; constructor($container: HTMLElement, options?: Partial<Options>) { this.$container = $container; if (getComputedStyle(this.$container).position === "static") { this.$container.style.position = "relative"; } this.options = { ...this.defaultOptions, ...options }; this.options.pixelRatio = Math.round(Math.max(this.options.pixelRatio, 1)); const { width, height } = this.options; this.$container.style.width = `${width}px`; this.$container.style.height = `${height}px`; this.$wrapper = document.createElement("div"); this.$wrapper.style.width = "0px"; this.$wrapper.style.height = "0px"; this.$canvas = document.createElement("canvas"); this.$canvas.width = width; this.$canvas.height = height; this.$canvas.style.display = "none"; this.$displayCanvas = document.createElement("canvas"); this.$displayCanvas.width = width; this.$displayCanvas.height = height; this.ctx = this.$canvas.getContext("2d")!; this.ctx.textAlign = "center"; this.displayCtx = this.$displayCanvas.getContext("2d")!; this.displayCtx.textAlign = "center"; this.$container.classList.add("tag2cloud"); this.$container.append(this.$canvas); this.$container.append(this.$displayCanvas); this.$container.append(this.$wrapper); this.initPixels(); this.initPoints(); } public async draw(tags: Tag[] = []): Promise<TagData[]> { if (tags.length === 0) return []; await this.promised; for (let i = 0, len = tags.length; i < len; i++) { const { weight } = tags[i]; if (weight > this.maxTagWeight) { this.maxTagWeight = weight; } if (weight < this.minTagWeight) { this.minTagWeight = weight; } } const result = await this.performDraw(tags); return result; } public clear() { const { width, height } = this.options; this.$wrapper.innerHTML = ""; this.displayCtx.clearRect(0, 0, width, height); this.initPixels(); } public destroy() { if (this.$container) { this.$container.innerHTML = ""; } } public shape(cb: (ctx: CanvasRenderingContext2D) => void) { const { width, height } = this.options; this.ctx.clearRect(0, 0, width, height); this.ctx.textAlign = "left"; cb(this.ctx); this.ctx.textAlign = "center"; const imgData = this.ctx.getImageData(0, 0, width, width); this.pixels = this.getPixelsFromImgData(imgData, 2, 255 * 3, -1, false); } public onClick(listener: Function): () => void { if (listener instanceof Function) { this.listeners.push(listener); return () => { this.offClick(listener); }; } return () => {}; } public offClick(listener: Function) { const index = this.listeners.indexOf(listener); if (index !== -1) { this.listeners.splice(index, 1); } } public getCtx(): CanvasRenderingContext2D { return this.ctx; } private initPixels() { const { width, height, maskImage } = this.options; if (maskImage) { const $img: HTMLImageElement = new Image(); this.promised = new Promise((resolve, reject) => { $img.onload = () => { this.pixels = this.loadMaskImage($img); resolve(); }; $img.onerror = reject; }); $img.crossOrigin = "anonymous"; $img.src = maskImage; } else { this.pixels = this.generatePixels(width, height, 0, false); } } private initPoints() { const { pixelRatio, width, height, shape } = this.options; const startX = width / 2; const startY = height / 2; const whRate = width / height; let d = pixelRatio; let theta = 0; let l = (Math.sqrt(width * width + height * height) - 100) >> 0; if (shape) { let minShapeRate = 1; while (d * minShapeRate < l) { theta += (pixelRatio / d) * 2; d += (pixelRatio / (d * 3)) * pixelRatio * 2; const r = d / 2; const shapeRate = shape(theta - Math.PI / 2); minShapeRate = Math.max(.3, Math.min(shapeRate, minShapeRate)); const rs = r * shapeRate; const x = (startX + Math.sin(theta) * rs * whRate) >> 0; const y = (startY + Math.cos(theta) * rs) >> 0; this.points.push(x); this.points.push(y); } } else { while (d < l) { theta += (pixelRatio / d) * 2; d += (pixelRatio / (d * 3)) * pixelRatio * 2; const r = d / 2; const x = (startX + Math.sin(theta) * r * whRate) >> 0; const y = (startY + Math.cos(theta) * r) >> 0; this.points.push(x); this.points.push(y); } } } private async performDraw(tags: Tag[] = []): Promise<TagData[]> { const sortTags = tags.sort((a, b) => b.weight - a.weight); const result: TagData[] = []; let partial: TagData[] = []; let expired = performance.now() + TIMEOUT_MS; for (let i = 0, len = sortTags.length; i < len; i++) { const tagData = this.handleTag(sortTags[i]); const now = performance.now(); result.push(tagData); partial.push(tagData); if (now > expired) { this.layout(partial); partial = []; await new Promise((r) => { setTimeout(r); }); expired = now + TIMEOUT_MS; } } this.layout(partial); return result; } private layout(data: TagData[]): void { if (this.options.canvas) { this.layoutByCanvas(data); } else { this.layoutByDom(data); } } private layoutByCanvas(data: TagData[]) { const { family } = this.options; for (let i = 0, len = data.length; i < len; i++) { const current = data[i]; if (!current.rendered) continue; const { angle, color, fontSize, text, x, y } = current; this.displayCtx.save(); const theta = (-angle * Math.PI) / 180; this.displayCtx.font = `${fontSize}px ${family}`; const textMetrics: TextMetrics = this.displayCtx.measureText(text); const { fontBoundingBoxAscent, fontBoundingBoxDescent } = textMetrics; const height = fontBoundingBoxAscent + fontBoundingBoxDescent; this.displayCtx.translate(x, y); this.displayCtx.rotate(theta); this.displayCtx.fillStyle = color; this.displayCtx.fillText(text, 0, height / 2 - fontBoundingBoxDescent); this.displayCtx.restore(); } } private layoutByDom(data: TagData[]) { const fragment = document.createDocumentFragment(); for (let i = 0, len = data.length; i < len; i++) { const current = data[i]; if (!current.rendered) continue; const $tag = document.createElement("span"); fragment.append($tag); $tag.innerText = current.text; $tag.style.color = current.color; $tag.style.justifyContent = "center"; $tag.style.alignItems = "center"; $tag.style.lineHeight = "normal"; $tag.style.fontSize = `${current.fontSize}px`; $tag.style.position = "absolute"; $tag.style.transform = `translate(calc(-50%), calc(-50%)) rotate(${-current.angle}deg)`; $tag.style.left = `${current.x}px`; $tag.style.top = `${current.y}px`; $tag.style.fontFamily = `${this.options.family}`; $tag.style.whiteSpace = "pre"; $tag.dataset.tag2cloud = current.text; $tag.classList.add("tag2cloud__tag"); $tag.addEventListener("click", this.click.bind(this, current)); } this.$wrapper.append(fragment); } private click(tagData: TagData) { this.listeners.forEach((fn: Function) => { fn(tagData); }); } private generatePixels( width: number, height: number, fill: -1 | 0 = 0, forTag: boolean = true ): Pixels { const { pixelRatio, cut } = this.options; const pixelXLength = Math.ceil(width / pixelRatio); const pixelYLength = Math.ceil(height / pixelRatio); const data = []; const len = Math.ceil(pixelXLength / 32); const tailOffset = pixelXLength % 32; const tailFill = forTag || tailOffset === 0 ? fill : cut ? fill & (-1 << (32 - tailOffset)) : fill | (-1 >>> tailOffset); for (let i = 0; i < pixelYLength; i++) { const xData = new Array(len).fill(fill); xData[len - 1] = tailFill; data.push(xData); } return { width, height, data }; } private handleTag(tag: Tag): TagData { const { minTagWeight, maxTagWeight } = this; const { minFontSize, maxFontSize, angleCount, angleFrom, angleTo, padding } = this.options; const { text, weight, angle: maybeAngle, color: maybeColor } = tag; const diffWeight = maxTagWeight - minTagWeight; const fontSize = diffWeight > 0 ? Math.round( minFontSize + (maxFontSize - minFontSize) * ((weight - minTagWeight) / diffWeight) ) : Math.round((maxFontSize + minFontSize) / 2); const randomNum = (Math.random() * angleCount) >> 0; const angle = maybeAngle === undefined ? angleCount === 1 ? angleFrom : angleFrom + (randomNum / (angleCount - 1)) * (angleTo - angleFrom) : maybeAngle; const color = maybeColor === undefined ? "#" + (((0xffff00 * Math.random()) >> 0) + 0x1000000).toString(16).slice(1) : maybeColor; const pixels = this.getTagPixels({ text, angle, fontSize, color, padding }); const result: TagData = { tag, text, weight, fontSize, angle, color, x: NaN, y: NaN, rendered: false }; if (pixels === null) return result; const [x, y] = this.placeTag(pixels); if (!isNaN(x)) { result.x = (x + pixels.width / 2) >> 0; result.y = (y + pixels.height / 2) >> 0; result.rendered = true; this.ctx.save(); } return result; } private placeTag(pixels: Pixels): [number, number] { const { width, height, pixelRatio } = this.options; const { width: pixelsWidth, height: pixelsHeight } = pixels; const halfW = (pixelsWidth / 2) >> 0; const halfH = (pixelsHeight / 2) >> 0; for (let i = 0, len = this.points.length; i < len; i += 2) { const [x, y] = [this.points[i] - halfW, this.points[i + 1] - halfH]; if (this.tryPlaceTag(pixels, x, y)) { return [x, y]; } } return [NaN, NaN]; } private tryPlaceTag(pixels: Pixels, x: number, y: number): boolean { const { pixelRatio, cut } = this.options; const { data } = pixels; const { data: thisData } = this.pixels; const pixelsX = Math.floor(x / pixelRatio); const pixelsY = Math.floor(y / pixelRatio); const offset = pixelsX % 32; const fix = offset ? -1 : 0; const xx = Math.floor(pixelsX / 32); const out = cut ? 0 : -1; for (let i = 0, len = data.length; i < len; i++) { const yData = thisData[pixelsY + i] === undefined ? [] : thisData[pixelsY + i]; for (let j = 0, len = data[i].length; j < len; j++) { const current = yData[xx + j] === undefined ? out : yData[xx + j]; const next = (yData[xx + j + 1] === undefined ? out : yData[xx + j + 1]) & fix; if (((current << offset) | (next >>> (32 - offset))) & data[i][j]) { return false; } } } for (let i = 0, len = data.length; i < len; i++) { const yData = thisData[pixelsY + i] === undefined ? [] : thisData[pixelsY + i]; for (let j = 0, len = data[i].length; j < len; j++) { const target = data[i][j]; if (yData[xx + j] !== undefined) { yData[xx + j] |= target >>> offset; } if (yData[xx + j + 1] !== undefined && offset) { yData[xx + j + 1] |= target << (32 - offset); } } } return true; } private getTagPixels({ text, angle, fontSize, color, padding }: { text: string; angle: number; fontSize: number; color: string; padding: number; }): null | Pixels { this.ctx.save(); const theta = (-angle * Math.PI) / 180; const cosTheta = Math.cos(theta); const sinTheta = Math.sin(theta); this.ctx.font = `${fontSize}px ${this.options.family}`; const textMetrics: TextMetrics = this.ctx.measureText(text); const { fontBoundingBoxAscent, fontBoundingBoxDescent, width } = textMetrics; const height = fontBoundingBoxAscent + fontBoundingBoxDescent; const widthWithPadding = width + padding; const heightWithPadding = height + padding; const pixelWidth = (Math.abs(heightWithPadding * sinTheta) + Math.abs(widthWithPadding * cosTheta)) >> 0; const pixelHeight = (Math.abs(heightWithPadding * cosTheta) + Math.abs(widthWithPadding * sinTheta)) >> 0; if (pixelHeight > this.options.height || pixelWidth > this.options.width) { return null; } this.ctx.clearRect(0, 0, pixelWidth, pixelHeight); this.ctx.translate(pixelWidth / 2, pixelHeight / 2); this.ctx.rotate(theta); this.ctx.fillStyle = color; this.ctx.lineWidth = padding; this.ctx.strokeText(text, 0, height / 2 - fontBoundingBoxDescent); this.ctx.fillText(text, 0, height / 2 - fontBoundingBoxDescent); this.ctx.restore(); const imgData: ImageData = this.ctx.getImageData( 0, 0, pixelWidth, pixelHeight ); return this.getPixelsFromImgData(imgData, 2, 255 * 3); } private getPixelsFromImgData( imgData: ImageData, opacityThreshold: number, lightThreshold: number, fill: 0 | -1 = 0, forTag: boolean = true ): Pixels { const { pixelRatio, cut } = this.options; const { data, width, height } = imgData; const pixels = this.generatePixels(width, height, fill, forTag); const { data: pixelsData } = pixels; const dataXLength = width << 2; const pixelXLength = Math.ceil(width / pixelRatio); const pixelYLength = Math.ceil(height / pixelRatio); let pixelCount = pixelXLength * pixelYLength; let pixelX = 0; let pixelY = 0; const edgeXLength = width % pixelRatio || pixelRatio; const edgeYLength = height % pixelRatio || pixelRatio; while (pixelCount--) { const outerOffset = pixelY * pixelRatio * dataXLength + ((pixelX * pixelRatio) << 2); const xLength = pixelX === pixelXLength - 1 ? edgeXLength : pixelRatio; const yLength = pixelY === pixelYLength - 1 ? edgeYLength : pixelRatio; const xIndex = (pixelX / 32) >> 0; let y = 0; outer: while (y < yLength) { let x = 0; const offset = outerOffset + y++ * dataXLength; while (x < xLength) { const pos = offset + (x++ << 2); const opacity = data[pos + 3]; if (opacity < opacityThreshold) { continue; } const light = data[pos] + data[pos + 1] + data[pos + 2]; if (light > lightThreshold) { continue; } if (fill) { pixelsData[pixelY][xIndex] &= ~(1 << -(pixelX + 1)); } else { pixelsData[pixelY][xIndex] |= 1 << -(pixelX + 1); } break outer; } } pixelX++; if (pixelX === pixelXLength) { pixelX = 0; pixelY++; } } return { width, height, data: pixelsData }; } private loadMaskImage($maskImage: HTMLImageElement): Pixels { const { width, height, opacityThreshold, lightThreshold } = this.options; this.ctx.clearRect(0, 0, width, height); this.ctx.drawImage($maskImage, 0, 0, width, height); const imgData = this.ctx.getImageData(0, 0, width, height); const pixels = this.getPixelsFromImgData( imgData, opacityThreshold, lightThreshold, -1, false ); return pixels; } private printPixels(pixels: Pixels | null): void { if (pixels === null) return; for (let i = 0, len = pixels.data.length; i < len; i++) { console.log(pixels.data[i].map(this.binaryStrIfy).join("") + "_" + i); } } private binaryStrIfy(num: number): string { if (num >= 0) { const numStr = num.toString(2); return ZERO_STR.slice(0, 32 - numStr.length) + numStr; } return (Math.pow(2, 32) + num).toString(2); } }