tag2cloud
Version:
make the tags cloud
649 lines (572 loc) • 19.5 kB
text/typescript
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);
}
}