├── .gitignore ├── .prettierrc ├── .vscode └── extensions.json ├── README.md ├── article ├── imgs │ ├── image-20240204143506046.png │ ├── image-20240204172948815.png │ ├── image-20240204182541132.png │ ├── image-20240204182717063.png │ ├── image-20240204190015172.png │ ├── image-20240205151752045.png │ ├── image-20240205151853732.png │ ├── image-20240205152653088.png │ ├── image-20240205154027691.png │ ├── image-20240205160515471.png │ ├── image-20240205164340620.png │ ├── image-20240205170517291.png │ ├── image-20240205170923369.png │ ├── image-20240205171240137.png │ ├── image-20240219092652404.png │ ├── image-20240219095637550.png │ ├── image-20240219102834306.png │ ├── image-20240219103053320.png │ ├── image-20240219141759813.png │ ├── image-20240219151658451.png │ ├── image-20240219153642854.png │ ├── image-20240219154856980.png │ ├── image-20240219155018087.png │ ├── image-20240219163001205.png │ ├── image-20240220102353638.png │ ├── image-20240220103025217.png │ └── image-20240220103155851.png └── 如何实现一个词云.md ├── docs ├── assets │ ├── index-6d648c29.css │ └── index-956a8f00.js ├── index.html └── vite.svg ├── index.html ├── package-lock.json ├── package.json ├── public └── vite.svg ├── simple-word-cloud ├── index.js ├── package.json └── src │ ├── WordItem.js │ ├── compute.js │ └── utils.js ├── src ├── App.vue ├── constant.js ├── example.js ├── main.js └── style.css └── vite.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | semi: false 2 | singleQuote: true 3 | printWidth: 80 4 | trailingComma: 'none' 5 | arrowParens: 'avoid' -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # simple-word-cloud 2 | 3 | > 一个简单的词云库 4 | 5 | ## 安装 6 | 7 | ```bash 8 | npm i simple-word-cloud 9 | ``` 10 | 11 | > 注意:源码未打包直接发布,有需要请自行配置打包文件。 12 | 13 | ## 使用 14 | 15 | ```html 16 |
17 | ``` 18 | 19 | ```js 20 | import SimpleWordCloud from 'simple-word-cloud' 21 | 22 | const wordCloud = new SimpleWordCloud({ 23 | el: document.getElementById('container') // 容器元素,大小不能为0 24 | // 其他配置选项 25 | }) 26 | wordCloud.render([ 27 | ['文字', 12, {}] // ['文字', 权重, 配置选项] 28 | // ... 29 | ]) 30 | ``` 31 | 32 | ## 文档 33 | 34 | ### 创建实例 35 | 36 | ```js 37 | const wordCloud = new SimpleWordCloud(options) 38 | ``` 39 | 40 | #### 参数options 41 | 42 | 对象类型,可以传递以下选项: 43 | 44 | | 属性 | 类型 | 默认值 | 描述 | 45 | | ------------------- | ---------------- | ------------------------- | ------------------------------------------------------------ | 46 | | el | DOM Element | | 容器元素,必填 | 47 | | minFontSize | Number | 12 | 文字最小的字号 | 48 | | maxFontSize | Number | 40 | 文字最大的字号 | 49 | | fontFamily | String | 微软雅黑, Microsoft YaHei | 字体 | 50 | | fontWeight | String 、 number | | 加粗 | 51 | | fontStyle | String | | 斜体 | 52 | | space | Number | 0 | 文字之间的间距,相对于字号,即该值会和字号相乘得到最终的间距,一般设置为0-1之间的小数 | 53 | | colorList | Array | 见下方 | 文字颜色列表 | 54 | | rotateType | String | none | 旋转类型,none(无)、cross(交叉,即要么是无旋转,要么是-90度旋转)、oblique(倾斜,即-45度旋转)、random(随机。即-90度到90度之间),如果要针对某个文本 | 55 | | fontSizeScale | Number | 1 / minFontSize | 计算时文字整体的缩小比例,用于加快计算速度,一般是0-1之间的小数,如果你没有非常清楚该配置的功能,那么请不要修改 | 56 | | transition | String | all 0.5s ease | 文本元素过渡动画,css的transition属性 | 57 | | smallWeightInCenter | Boolean | false | 按权重从小到大的顺序渲染,默认是按权重从大到小进行渲染 | 58 | | onClick(v1.0.1+) | Function | | 监听词云的点击事件。接收一个参数,代表被点击的词云数据。 | 59 | 60 | ##### 默认颜色列表 61 | 62 | ```js 63 | [ 64 | '#326BFF', 65 | '#5C27FE', 66 | '#C165DD', 67 | '#FACD68', 68 | '#FC76B3', 69 | '#1DE5E2', 70 | '#B588F7', 71 | '#08C792', 72 | '#FF7B02', 73 | '#3bc4c7', 74 | '#3a9eea', 75 | '#461e47', 76 | '#ff4e69' 77 | ] 78 | ``` 79 | 80 | ### 方法 81 | 82 | #### run(*words* = [], done = () => {}) 83 | 84 | - `words`:数组,每一项也是一个数组,结构为:['文字','权重', '配置'],比如: 85 | 86 | ```js 87 | [ 88 | ['文字', 12, { 89 | rotate: 45 90 | }] 91 | ] 92 | ``` 93 | 94 | 所有可用配置如下: 95 | 96 | ```js 97 | { 98 | rotate,// Number,旋转角度 99 | space,// 同实例选项的space 100 | color,// 文字颜色,不设置则随机 101 | fontFamily,// 字体 102 | fontWeight,// 加粗 103 | fontStyle// 斜体 104 | } 105 | ``` 106 | 107 | - `done`:回调函数,接收一个参数,词云实例列表,你可以根据该列表进行渲染 108 | 109 | 仅计算词云位置,不包含渲染操作,所以你需要拿到计算完位置和大小后的词云实例列表来自行渲染。 110 | 111 | 112 | 113 | #### render(words, done = () => {}) 114 | 115 | 计算并使用DOM方式直接渲染到容器内。 116 | 117 | #### renderUseCanvas(words, done = () => {}) 118 | 119 | > v1.0.1+ 120 | 121 | 计算并使用Canvas方式直接渲染到容器内。 122 | 123 | #### exportCanvas(isDownload = true, fileName = 'wordCloud') 124 | 125 | > v1.0.1+ 126 | 127 | - `isDownload`:是否直接触发下载,为false则返回data:URL数据 128 | 129 | 导出画布为图片,只有当使用renderUseCanvas方法渲染时才有效。 130 | 131 | #### clear() 132 | 133 | > v1.0.1+ 134 | 135 | 清除渲染的数据。 136 | 137 | #### updateOption(options) 138 | 139 | 更新配置,`options`同实例化配置。不包含`el`选项。 140 | 141 | 142 | 143 | #### resize() 144 | 145 | 当容器大小改变了需要调用该方法。此外,你需要自行再次调用`run`方法或`render`方法。 146 | 147 | 148 | 149 | ## 本地开发 150 | 151 | ```bash 152 | git clone https://github.com/wanglin2/simple-word-cloud.git 153 | cd simple-word-cloud 154 | npm i 155 | npm link 156 | cd .. 157 | npm i 158 | npm link simple-word-cloud 159 | npm run dev 160 | ``` 161 | -------------------------------------------------------------------------------- /article/imgs/image-20240204143506046.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240204143506046.png -------------------------------------------------------------------------------- /article/imgs/image-20240204172948815.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240204172948815.png -------------------------------------------------------------------------------- /article/imgs/image-20240204182541132.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240204182541132.png -------------------------------------------------------------------------------- /article/imgs/image-20240204182717063.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240204182717063.png -------------------------------------------------------------------------------- /article/imgs/image-20240204190015172.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240204190015172.png -------------------------------------------------------------------------------- /article/imgs/image-20240205151752045.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240205151752045.png -------------------------------------------------------------------------------- /article/imgs/image-20240205151853732.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240205151853732.png -------------------------------------------------------------------------------- /article/imgs/image-20240205152653088.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240205152653088.png -------------------------------------------------------------------------------- /article/imgs/image-20240205154027691.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240205154027691.png -------------------------------------------------------------------------------- /article/imgs/image-20240205160515471.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240205160515471.png -------------------------------------------------------------------------------- /article/imgs/image-20240205164340620.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240205164340620.png -------------------------------------------------------------------------------- /article/imgs/image-20240205170517291.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240205170517291.png -------------------------------------------------------------------------------- /article/imgs/image-20240205170923369.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240205170923369.png -------------------------------------------------------------------------------- /article/imgs/image-20240205171240137.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240205171240137.png -------------------------------------------------------------------------------- /article/imgs/image-20240219092652404.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240219092652404.png -------------------------------------------------------------------------------- /article/imgs/image-20240219095637550.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240219095637550.png -------------------------------------------------------------------------------- /article/imgs/image-20240219102834306.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240219102834306.png -------------------------------------------------------------------------------- /article/imgs/image-20240219103053320.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240219103053320.png -------------------------------------------------------------------------------- /article/imgs/image-20240219141759813.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240219141759813.png -------------------------------------------------------------------------------- /article/imgs/image-20240219151658451.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240219151658451.png -------------------------------------------------------------------------------- /article/imgs/image-20240219153642854.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240219153642854.png -------------------------------------------------------------------------------- /article/imgs/image-20240219154856980.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240219154856980.png -------------------------------------------------------------------------------- /article/imgs/image-20240219155018087.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240219155018087.png -------------------------------------------------------------------------------- /article/imgs/image-20240219163001205.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240219163001205.png -------------------------------------------------------------------------------- /article/imgs/image-20240220102353638.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240220102353638.png -------------------------------------------------------------------------------- /article/imgs/image-20240220103025217.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240220103025217.png -------------------------------------------------------------------------------- /article/imgs/image-20240220103155851.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanglin2/simple-word-cloud/acf8370d31834451e65d2e6e6f29e53e7b26352a/article/imgs/image-20240220103155851.png -------------------------------------------------------------------------------- /article/如何实现一个词云.md: -------------------------------------------------------------------------------- 1 | 词云是一种文本数据的可视化形式,它富有表现力,通过大小不一,五颜六色,随机紧挨在一起的文本形式,可以在众多文本中直观地突出出现频率较高的关键词,给予视觉上的突出,从而过滤掉大量的文本信息,在实际项目中,我们可以选择使用[wordcloud2](https://github.com/timdream/wordcloud2.js)、[VueWordCloud](https://github.com/SeregPie/VueWordCloud)等开源库来实现,但是你有没有好奇过它是怎么实现的呢,本文会尝试从0实现一个简单的词云效果。 2 | 3 | 最终效果抢先看:[https://wanglin2.github.io/simple-word-cloud/](https://wanglin2.github.io/simple-word-cloud/)。 4 | 5 | # 基本原理 6 | 7 | 词云的基本实现原理非常简单,就是通过遍历像素点进行判断,我们可以依次遍历每个文本的每个像素点,然后再依次扫描当前画布的每个像素点,然后判断这个像素点的位置能否容纳当前文本,也就是不会和已经存在的文本重叠,如果可以的话这个像素点的位置就是该文本显示的位置。 8 | 9 | 获取文本的像素点我们可以通过`canvas`的`getImageData`方法。 10 | 11 | 最终渲染你可以直接使用`canvas`,也可以使用`DOM`,本文会选择使用`DOM`,因为可以更方便的修改内容、样式以及添加交互事件。 12 | 13 | # 计算文字大小 14 | 15 | 假如我们接收的源数据结构如下所示: 16 | 17 | ```js 18 | const words = [ 19 | ['字节跳动', 33], 20 | ['腾讯', 21], 21 | ['阿里巴巴', 4], 22 | ['美团', 56], 23 | ] 24 | ``` 25 | 26 | 每个数组的第一项代表文本,第二项代表该文本所对应的权重大小,权重越大,在词云图中渲染时的字号也越大。 27 | 28 | 那么怎么根据这个权重来计算出所对应的文字大小呢,首先我们可以找出所有文本中权重的最大值和最小值,那么就可以得到权重的区间,然后将每个文本的权重减去最小的权重,除以总的区间,就可以得到这个文本的权重在总的区间中的所占比例,同时,我们需要设置词云图字号允许的最小值和最大值,那么只要和字号的区间相乘,就可以得到权重对应的字号大小,基于此我们可以写出以下函数: 29 | 30 | ```js 31 | // 根据权重计算字号 32 | const getFontSize = ( 33 | weight, 34 | minWeight, 35 | maxWeight, 36 | minFontSize, 37 | maxFontSize 38 | ) => { 39 | const weightRange = maxWeight - minWeight 40 | const fontSizeRange = maxFontSize - minFontSize 41 | const curWeightRange = weight - minWeight 42 | return minFontSize + (curWeightRange / weightRange) * fontSizeRange 43 | } 44 | ``` 45 | 46 | # 获取文本的像素数据 47 | 48 | `canvas`有一个`getImageData`方法可以获取画布的像素数据,那么我们就可以将文本在`canvas`上绘制出来,然后再调用该方法就能得到文本的像素数据了。 49 | 50 | 文本的字体样式不同,绘制出来的文本也不一样,所以绘制前需要设置一下字体的各种属性,比如字号、字体、加粗、斜体等等,可以通过绘图上下文的`font`属性来设置,本文简单起见,只支持字号、字体、加粗三个字体属性。 51 | 52 | 因为`canvas`不像`css`一样支持单个属性进行设置,所以我们写一个工具方法来拼接字体样式: 53 | 54 | ```js 55 | // 拼接font字符串 56 | const joinFontStr = ({ fontSize, fontFamily, fontWeight }) => { 57 | return `${fontWeight} ${fontSize}px ${fontFamily} ` 58 | } 59 | ``` 60 | 61 | 接下来还要考虑的一个问题是`canvas`的大小是多少,很明显,只要能容纳文本就够了,所以也就是文本的大小,`canvas`同样也提供了测量文本大小的方法`measureText`,那么我们可以写出如下的工具方法: 62 | 63 | ```js 64 | // 获取文本宽高 65 | let measureTextContext = null 66 | const measureText = (text, fontStyle) => { 67 | // 创建一个canvas用于测量 68 | if (!measureTextContext) { 69 | const canvas = document.createElement('canvas') 70 | measureTextContext = canvas.getContext('2d') 71 | } 72 | measureTextContext.save() 73 | // 设置字体样式 74 | measureTextContext.font = joinFontStr(fontStyle) 75 | // 测量文本 76 | const { width, actualBoundingBoxAscent, actualBoundingBoxDescent } = 77 | measureTextContext.measureText(text) 78 | measureTextContext.restore() 79 | // 返回文本宽高 80 | const height = actualBoundingBoxAscent + actualBoundingBoxDescent 81 | return { width, height } 82 | } 83 | ``` 84 | 85 | `measureText`方法不会直接返回高度,所以我们要通过返回的其他属性计算得出,关于`measureText`更详细的介绍可以参考[measureText](https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/measureText)。 86 | 87 | 有了以上两个方法,我们就可以写出如下的方法来获取文本的像素数据: 88 | 89 | ```js 90 | // 获取文字的像素点数据 91 | export const getTextImageData = (text, fontStyle) => { 92 | const canvas = document.createElement('canvas') 93 | // 获取文本的宽高,并向上取整 94 | let { width, height } = measureText(text, fontStyle) 95 | width = Math.ceil(width) 96 | height = Math.ceil(height) 97 | canvas.width = width 98 | canvas.height = height 99 | const ctx = canvas.getContext('2d') 100 | // 绘制文本 101 | ctx.translate(width / 2, height / 2) 102 | ctx.font = joinFontStr(textStyle) 103 | ctx.textAlign = 'center' 104 | ctx.textBaseline = 'middle' 105 | ctx.fillText(text, 0, 0) 106 | // 获取画布的像素数据 107 | const image = ctx.getImageData(0, 0, width, height).data 108 | // 遍历每个像素点,找出有内容的像素点 109 | const imageData = [] 110 | for (let x = 0; x < width; x++) { 111 | for (let y = 0; y < height; y++) { 112 | // 如果a通道不为0,那么代表该像素点存在内容 113 | const a = image[x * 4 + y * (width * 4) + 3] 114 | if (a > 0) { 115 | imageData.push([x, y]) 116 | } 117 | } 118 | } 119 | return { 120 | data: imageData, 121 | width, 122 | height 123 | } 124 | } 125 | ``` 126 | 127 | 首先为了避免出现小数,我们将计算出的文本大小向上取整作为画布的大小。 128 | 129 | 然后将画布的中心点从左上角移到中心进行文本的绘制。 130 | 131 | 接下来通过`getImageData`方法获取到画布的像素数据,获取到的是一个数值数组,依次保存着画布从左到右,从上到下的每一个像素点的信息,每四位代表一个像素点,分别为:`r`、`g`、`b`、`a`四个通道的值。 132 | 133 | ![image-20240204143506046](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240204143506046.png) 134 | 135 | 为了减少后续比对的工作量,我们过滤出存在内容的像素点,也就是存在文本的像素点,空白的像素点可以直接舍弃。因为我们没有指定文本的颜色,所以默认为黑色,也就是`rgb(0,0,0)`,那么只能通过`a`通道来判断。 136 | 137 | 另外,除了返回存在内容的像素点数据外,也返回了文本的宽高信息,后续可能会用到。 138 | 139 | # 文本类 140 | 141 | 接下来我们来创建一个文本类,用于保存每个文本的一些私有状态: 142 | 143 | ```js 144 | // 文本类 145 | class WordItem { 146 | constructor({ text, weight, fontStyle, color }) { 147 | // 文本 148 | this.text = text 149 | // 权重 150 | this.weight = weight 151 | // 字体样式 152 | this.fontStyle = fontStyle 153 | // 文本颜色 154 | this.color = color || getColor()// getColor方法是一个返回随机颜色的方法 155 | // 文本像素数据 156 | this.imageData = getTextImageData(text, fontStyle) 157 | // 文本渲染的位置 158 | this.left = 0 159 | this.top = 0 160 | } 161 | } 162 | ``` 163 | 164 | 很简单,保存相关的状态,并且计算并保存文本的像素数据。 165 | 166 | # 词云类 167 | 168 | 接下来创建一下我们的入口类: 169 | 170 | ```js 171 | // 词云类 172 | class WordCloud { 173 | constructor({ el, minFontSize, maxFontSize, fontFamily, fontWeight }) { 174 | // 词云渲染的容器元素 175 | this.el = el 176 | const elRect = el.getBoundingClientRect() 177 | this.elWidth = elRect.width 178 | this.elHeight = elRect.height 179 | // 字号大小 180 | this.minFontSize = minFontSize || 12 181 | this.maxFontSize = maxFontSize || 40 182 | // 字体 183 | this.fontFamily = fontFamily || '微软雅黑' 184 | // 加粗 185 | this.fontWeight = fontWeight || '' 186 | } 187 | } 188 | ``` 189 | 190 | 后续的计算中会用到容器的大小,所以需要保存一下。此外也开放了字体样式的配置。 191 | 192 | 接下来添加一个计算的方法: 193 | 194 | ```js 195 | class WordCloud { 196 | // 计算词云位置 197 | run(words = [], done = () => {}) { 198 | // 按权重从大到小排序 199 | const wordList = [...words].sort((a, b) => { 200 | return b[1] - a[1] 201 | }) 202 | const minWeight = wordList[wordList.length - 1][1] 203 | const maxWeight = wordList[0][1] 204 | // 创建词云文本实例 205 | const wordItemList = wordList 206 | .map(item => { 207 | const text = item[0] 208 | const weight = item[1] 209 | return new WordItem({ 210 | text, 211 | weight, 212 | fontStyle: { 213 | fontSize: getFontSize( 214 | weight, 215 | minWeight, 216 | maxWeight, 217 | this.minFontSize, 218 | this.maxFontSize 219 | ), 220 | fontFamily: this.fontFamily, 221 | fontWeight: this.fontWeight 222 | } 223 | }) 224 | }) 225 | } 226 | } 227 | ``` 228 | 229 | `run`方法接收两个参数,第一个为文本列表,第二个为执行完成时的回调函数,会把最终的计算结果传递回去。 230 | 231 | 首先我们把文本列表按权重从大到小进行了排序,因为词云的渲染中一般权重大的文本会渲染在中间位置,所以我们从大到小进行计算。 232 | 233 | 然后给每个文本创建了一个文本实例。 234 | 235 | 我们可以这么使用这个类: 236 | 237 | ```js 238 | const wordCloud = new WordCloud({ 239 | el: el.value 240 | }) 241 | wordCloud.run(words, () => {}) 242 | ``` 243 | 244 | ![image-20240204172948815](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240204172948815.png) 245 | 246 | # 计算文本的渲染位置 247 | 248 | 接下来到了核心部分,即如何计算出每个文本的渲染位置。 249 | 250 | 具体逻辑如下: 251 | 252 | 1.我们会维护一个`map`,`key`为像素点的坐标,`value`为`true`,代表这个像素点已经有内容了。 253 | 254 | 2.以第一个文本,也就是权重最大的文本作为基准,你可以想象成它就是画布,其他文本都相对它进行定位,首先将它的所有像素点保存到`map`中,同时记录下它的中心点位置; 255 | 256 | 3.依次遍历后续的每个文本实例,对每个文本实例,从中心点依次向四周扩散,遍历每个像素点,根据每个文本的像素数据和`map`中的数据判断当前像素点的位置能否容纳该文本,可以的话这个像素点即作为该文本最终渲染的位置,也就是想象成渲染到第一个文本形成的画布上,然后将当前文本的像素数据也添加到`map`中,不过要注意,这时每个像素坐标都需要加上计算出来的位置,因为我们是以第一个文本作为基准。以此类推,计算出所有文本的位置。 257 | 258 | 添加一个`compute`方法: 259 | 260 | ```js 261 | class WordCloud { 262 | run(words = [], done = () => {}) { 263 | // ... 264 | // 计算文本渲染的位置 265 | this.compute(wordItemList) 266 | // 返回计算结果 267 | const res = wordItemList.map(item => { 268 | return { 269 | text: item.text, 270 | left: item.left, 271 | top: item.top, 272 | color: item.color, 273 | fontStyle: item.fontStyle 274 | } 275 | }) 276 | done(res) 277 | } 278 | 279 | // 计算文本的位置 280 | compute(wordItemList) { 281 | for (let i = 0; i < wordItemList.length; i++) { 282 | const curWordItem = wordItemList[i] 283 | // 将第一个文本的像素数据保存到map中 284 | if (i === 0) { 285 | addToMap(curWordItem) 286 | continue 287 | } 288 | // 依次计算后续的每个文本的显示位置 289 | const res = getPosition(curWordItem) 290 | curWordItem.left = res[0] 291 | curWordItem.top = res[1] 292 | // 计算出位置后的每个文本也需要将像素数据保存到map中 293 | addToMap(curWordItem) 294 | } 295 | } 296 | } 297 | ``` 298 | 299 | 调用`compute`方法计算出每个文本的渲染位置,计算完后我们会调用`done`方法把文本数据传递出去。 300 | 301 | `compute`方法就是前面描述的`2`、`3`两步的逻辑,接下来我们的任务就是完成其中的`addToMap`、`getPosition`两个方法。 302 | 303 | `addToMap`方法用于保存每个文本的像素数据,同时要记录一下第一个文本的中心点位置: 304 | 305 | ```js 306 | let pxMap = {} 307 | let centerX = -1 308 | let centerY = -1 309 | 310 | // 保存每个文本的像素数据 311 | const addToMap = curWordItem => { 312 | curWordItem.imageData.data.forEach(item => { 313 | const x = item[0] + curWordItem.left 314 | const y = item[1] + curWordItem.top 315 | pxMap[x + '|' + y] = true 316 | }) 317 | // 记录第一个文本的中心点位置 318 | if (centerX === -1 && centerY === -1) { 319 | centerX = Math.floor(curWordItem.imageData.width / 2) 320 | centerY = Math.floor(curWordItem.imageData.height / 2) 321 | } 322 | } 323 | ``` 324 | 325 | 很简单,遍历文本的像素数据,以坐标为`key`添加到`map`对象中。 326 | 327 | 可以看到每个像素点的坐标会加上当前文本的渲染坐标,初始都为0,所以第一个文本保存的就是它原始的坐标值,后续每个文本都是渲染在第一个文本形成的画布上,所以每个像素点要加上它的渲染坐标,才能转换成第一个文本形成的画布的坐标系上的点。 328 | 329 | 接下来是`getPosition`方法,首先来看一下示意图: 330 | 331 | ![image-20240204182541132](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240204182541132.png) 332 | 333 | 遍历的起点是第一个文本的中心点,然后不断向四周扩散: 334 | 335 | ![image-20240204182717063](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240204182717063.png) 336 | 337 | 每次扩散形成的矩形的四条边上的所有像素点都需要遍历判断是否符合要求,即这个位置能否容纳当前文本,所以我们需要四个循环。 338 | 339 | 这样不断扩散,直到找到符合要求的坐标。 340 | 341 | 因为要向四周扩散,所以需要四个变量来保存: 342 | 343 | ```js 344 | const getPosition = (curWordItem) => { 345 | let startX, endX, startY, endY 346 | // 第一个文本的中心点 347 | startX = endX = centerX 348 | startY = endY = centerY 349 | } 350 | ``` 351 | 352 | 以第一个文本的中心点为起始点,也就是开始遍历的位置。初始`startX`和`endX`相同,`startY`和`endY`相同,然后`startX`和`startY`递减,`endX`和`endY`递增,达到扩散的效果。 353 | 354 | 针对每个像素点,我们怎么判断它是否符合要求呢,很简单,遍历当前文本的每个像素点,加上当前判断的像素点的坐标,转换成第一个文本形成的坐标系上的点,然后去`map`里面找,如果某个像素点已经在`map`中存在了,代表这个像素点已经有文本了,那么当前被检查的这个像素所在的位置无法就完全容纳当前文本,那么进入下一个像素点进行判断,直到找到符合要求的点。 355 | 356 | ```js 357 | // 判断某个像素点所在位置能否完全容纳某个文本 358 | const canFit = (curWordItem, [cx, cy]) => { 359 | if (pxMap[`${cx}|${cy}`]) return false 360 | return curWordItem.imageData.data.every(([x, y]) => { 361 | const left = x + cx 362 | const top = y + cy 363 | return !pxMap[`${left}|${top}`] 364 | }) 365 | } 366 | ``` 367 | 368 | 首先判断这个像素位置本身是否已经存在文字了,如果没有,那么遍历文本的所有像素点,需要注意文本的每个像素坐标都要加上当前判断的像素坐标,这样才是以第一个文本为基准的坐标值。 369 | 370 | 有了这个方法,接下来就可以遍历所有像素点节点判断了: 371 | 372 | ![image-20240204190015172](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240204190015172.png) 373 | 374 | ```js 375 | const getPosition = (curWordItem) => { 376 | let startX, endX, startY, endY 377 | startX = endX = centerX 378 | startY = endY = centerY 379 | // 判断起始点是否符合要求 380 | if (canFit(curWordItem, [startX, startY])) { 381 | return [startX, startY] 382 | } 383 | // 依次扩散遍历每个像素点 384 | while (true) { 385 | // 向下取整作为当前比较的值 386 | const curStartX = Math.floor(startX) 387 | const curStartY = Math.floor(startY) 388 | const curEndX = Math.floor(endX) 389 | const curEndY = Math.floor(endY) 390 | 391 | // 遍历矩形右侧的边 392 | for (let top = curStartY; top < curEndY; ++top) { 393 | const value = [curEndX, top] 394 | if (canFit(curWordItem, value)) { 395 | return value 396 | } 397 | } 398 | // 遍历矩形下面的边 399 | for (let left = curEndX; left > curStartX; --left) { 400 | const value = [left, curEndY] 401 | if (canFit(curWordItem, value)) { 402 | return value 403 | } 404 | } 405 | // 遍历矩形左侧的边 406 | for (let top = curEndY; top > curStartY; --top) { 407 | const value = [curStartX, top] 408 | if (canFit(curWordItem, value)) { 409 | return value 410 | } 411 | } 412 | // 遍历矩形上面的边 413 | for (let left = curStartX; left < curEndX; ++left) { 414 | const value = [left, curStartY] 415 | if (canFit(curWordItem, value)) { 416 | return value 417 | } 418 | } 419 | 420 | // 向四周扩散 421 | startX -= 1 422 | endX += 1 423 | startY -= 1 424 | endY += 1 425 | } 426 | } 427 | ``` 428 | 429 | 因为我们是通过像素的坐标来判断,所以不允许出现小数,都需要进行取整。 430 | 431 | 对矩形边的遍历我们是按下图的方向: 432 | 433 | ![image-20240205151752045](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240205151752045.png) 434 | 435 | 当然,你也可以调整成你喜欢的顺序。 436 | 437 | 到这里,应该就可以计算出所有文本的渲染位置了,我们将文本渲染出来看看效果: 438 | 439 | ```js 440 | import { ref } from 'vue' 441 | 442 | const el = ref(null) 443 | const list = ref([]) 444 | 445 | const wordCloud = new WordCloud({ 446 | el: el.value 447 | }) 448 | wordCloud.run(exampleData, res => { 449 | list.value = res 450 | }) 451 | ``` 452 | 453 | ```html 454 |
455 |
467 | {{ item.text }} 468 |
469 |
470 | ``` 471 | 472 | ```css 473 | .container { 474 | width: 600px; 475 | height: 400px; 476 | border: 1px solid #000; 477 | margin: 200px auto; 478 | position: relative; 479 | 480 | .wordItem { 481 | position: absolute; 482 | white-space: nowrap; 483 | } 484 | } 485 | ``` 486 | 487 | 以上是`Vue3`的代码示例,容器元素设为相对定位,文本元素设为绝对定位,然后将计算出来的位置作为`left`和`top`值,不要忘了设置字号、字体等样式。效果如下: 488 | 489 | ![image-20240205151853732](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240205151853732.png) 490 | 491 | 为了方便的看出每个文本的权重,把权重值也显示出来了。 492 | 493 | 首先可以看到有极少数文字还是发生了重叠,这个其实很难避免,因为我们一直在各种取整。 494 | 495 | 另外可以看到文字的分布是和我们前面遍历的顺序是一致的。 496 | 497 | # 适配容器 498 | 499 | 现在我们看一下文本数量比较多的情况: 500 | 501 | ![image-20240205152653088](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240205152653088.png) 502 | 503 | 可以看到我们给的容器是宽比高长的,而渲染出来云图接近一个正方形,这样放到容器里显然没办法完全铺满,所以最好我们计算出来的云图的比例和容器的比例是一致的。 504 | 505 | 解决这个问题可以从扩散的步长下手,目前我们向四周扩散的步长都是1,假如宽比高长,那么垂直方向已经扩散出当前像素区域了,而水平方向还在内部,那么显然最后垂直方向上排列的就比较多了,我们要根据容器的长宽比来调整这个步长,让垂直和水平方向扩散到边界的时间是一样的: 506 | 507 | ```js 508 | class WordCloud { 509 | compute(wordItemList) { 510 | for (let i = 0; i < wordItemList.length; i++) { 511 | const curWordItem = wordItemList[i] 512 | // ... 513 | // 计算文本渲染位置时传入容器的宽高 514 | const res = getPosition({ 515 | curWordItem, 516 | elWidth: this.elWidth, 517 | elHeight: this.elHeight 518 | }) 519 | // ... 520 | } 521 | } 522 | } 523 | ``` 524 | 525 | ```js 526 | const getPosition = ({ elWidth, elHeight, curWordItem }) => { 527 | // ... 528 | // 根据容器的宽高来计算扩散步长 529 | let stepLeft = 1, 530 | stepTop = 1 531 | if (elWidth > elHeight) { 532 | stepLeft = 1 533 | stepTop = elHeight / elWidth 534 | } else if (elHeight > elWidth) { 535 | stepTop = 1 536 | stepLeft = elWidth / elHeight 537 | } 538 | // ... 539 | while (true) { 540 | // ... 541 | startX -= stepLeft 542 | endX += stepLeft 543 | startY -= stepTop 544 | endY += stepTop 545 | } 546 | } 547 | ``` 548 | 549 | 计算文本渲染位置时传入容器的宽高,如果宽比高长,那么垂直方向步长就得更小一点,反之亦然。 550 | 551 | 此时我们再来看看效果: 552 | 553 | ![image-20240205154027691](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240205154027691.png) 554 | 555 | 是不是基本上一致了。 556 | 557 | 现在我们来看下一个问题,那就是大小适配,我们将最小的文字大小调大一点看看: 558 | 559 | ![image-20240205160515471](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240205160515471.png) 560 | 561 | 可以发现词云已经比容器大了,这显然不行,所以最后我们还要来根据容器大小来调整词云的大小,怎么调整呢,根据容器大小缩放词云整体的位置和字号。 562 | 563 | 首先我们要知道词云整体的大小,这可以最后遍历`map`来计算,当然也可以在`addToMap`函数内同时计算: 564 | 565 | ```js 566 | let left = Infinity 567 | let right = -Infinity 568 | let top = Infinity 569 | let bottom = -Infinity 570 | 571 | const addToMap = curWordItem => { 572 | curWordItem.imageData.data.forEach(item => { 573 | const x = item[0] + curWordItem.left 574 | const y = item[1] + curWordItem.top 575 | pxMap[x + '|' + y] = true 576 | // 更新边界 577 | left = Math.min(left, x) 578 | right = Math.max(right, x) 579 | top = Math.min(top, y) 580 | bottom = Math.max(bottom, y) 581 | }) 582 | // ... 583 | } 584 | 585 | // 获取边界数据 586 | const getBoundingRect = () => { 587 | return { 588 | left, 589 | right, 590 | top, 591 | bottom, 592 | width: right - left, 593 | height: bottom - top 594 | } 595 | } 596 | ``` 597 | 598 | 增加了四个变量来保存所有文本渲染后的边界数据,同时添加了一个函数来获取这个信息。 599 | 600 | 接下来给`WordCloud`类增加一个方法,用来适配容器的大小: 601 | 602 | ```js 603 | class WordCloud { 604 | run(words = [], done = () => {}) { 605 | // ... 606 | this.compute(wordItemList) 607 | this.fitContainer(wordItemList)// ++ 608 | const res = wordItemList.map(item => { 609 | return {} 610 | }) 611 | done(res) 612 | } 613 | 614 | // 根据容器大小调整字号 615 | fitContainer(wordItemList) { 616 | const elRatio = this.elWidth / this.elHeight 617 | const { width, height } = getBoundingRect() 618 | const wordCloudRatio = width / height 619 | let w, h 620 | if (elRatio > wordCloudRatio) { 621 | // 词云高度以容器高度为准,宽度根据原比例进行缩放 622 | h = this.elHeight 623 | w = wordCloudRatio * this.elHeight 624 | } else { 625 | // 词云宽度以容器宽度为准,高度根据原比例进行缩放 626 | w = this.elWidth 627 | h = this.elWidth / wordCloudRatio 628 | } 629 | const scale = w / width 630 | wordItemList.forEach(item => { 631 | item.left *= scale 632 | item.top *= scale 633 | item.fontStyle.fontSize *= scale 634 | }) 635 | } 636 | } 637 | ``` 638 | 639 | 根据词云的宽高比和容器的宽高比进行缩放,计算出缩放倍数,然后应用到词云所有文本的渲染坐标、字号上。现在再来看看效果: 640 | 641 | ![image-20240205164340620](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240205164340620.png) 642 | 643 | 现在还有最后一个问题要解决,就是渲染位置的调整,因为目前所有文本渲染的位置都是相对于第一个文本的,因为第一个文本的位置为`0,0`,所以它处于容器的左上角,我们要调整为整体在容器中居中。 644 | 645 | ![image-20240205170517291](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240205170517291.png) 646 | 647 | 如图所示,第一个文本的位置为`0,0`,所以左边和上边超出的距离就是边界数据中的`left`、`top`值,那么把词云移入容器,只要整体移动`-left`、`-top`距离即可。 648 | 649 | 接下来是移动到中心,这个只要根据前面的比例来判断移动水平还是垂直的位置即可: 650 | 651 | ![image-20240205170923369](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240205170923369.png) 652 | 653 | 所以这个逻辑也可以写在`fitContainer`方法中: 654 | 655 | ```js 656 | class WordCloud { 657 | fitContainer(wordItemList) { 658 | const elRatio = this.elWidth / this.elHeight 659 | let { width, height, left, top } = getBoundingRect() 660 | const wordCloudRatio = width / height 661 | let w, h 662 | // 整体平移距离 663 | let offsetX = 0, 664 | offsetY = 0 665 | if (elRatio > wordCloudRatio) {} else {} 666 | const scale = w / width 667 | // 将词云移动到容器中间 668 | left *= scale 669 | top *= scale 670 | if (elRatio > wordCloudRatio) { 671 | offsetY = -top 672 | offsetX = -left + (this.elWidth - w) / 2 673 | } else { 674 | offsetX = -left 675 | offsetY = -top + (this.elHeight - h) / 2 676 | } 677 | wordItemList.forEach(item => { 678 | item.left *= scale 679 | item.top *= scale 680 | item.left += offsetX 681 | item.top += offsetY 682 | item.fontStyle.fontSize *= scale 683 | }) 684 | } 685 | } 686 | ``` 687 | 688 | ![image-20240205171240137](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240205171240137.png) 689 | 690 | 到这里,一个基本的词云效果就完成了。 691 | 692 | # 加快速度 693 | 694 | 以上代码可以工作,但是它的速度非常慢,因为要遍历的像素点数据比较庞大,所以耗时是以分钟计的: 695 | 696 | ![image-20240219092652404](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240219092652404.png) 697 | 698 | 这显然是无法接受的,浏览器都无法忍受弹出了退出页面的提示,那么怎么减少一点时间呢,前面说了首先是因为要遍历的像素点太多了,那么是不是可以减少像素点呢,当然是可以的,我们最后有一步适配容器大小的操作,既然都是要最后来整体缩放的,那不如一开始就给所有的文本的字号缩小一定倍数,字号小了,那么像素点显然也会变少,进而计算的速度就会加快: 699 | 700 | ```js 701 | class WordCloud { 702 | constructor({ el, minFontSize, maxFontSize, fontFamily, fontWeight, fontSizeScale }) { 703 | // ... 704 | // 文字整体的缩小比例,用于加快计算速度 705 | this.fontSizeScale = fontSizeScale || 0.1 706 | } 707 | 708 | run(words = [], done = () => {}) { 709 | // ... 710 | const wordItemList = wordList.map(item => { 711 | const text = item[0] 712 | const weight = item[1] 713 | return new WordItem({ 714 | text, 715 | weight, 716 | fontStyle: { 717 | fontSize: getFontSize() * this.fontSizeScale,// ++ 718 | fontFamily: this.fontFamily, 719 | fontWeight: this.fontWeight 720 | } 721 | }) 722 | }) 723 | } 724 | } 725 | ``` 726 | 727 | 这个比例你可以自己调整,越小速度越快,当然,也不能太小,太小文字都渲染不了了。现在来看一下耗时: 728 | 729 | ![image-20240219141759813](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240219141759813.png) 730 | 731 | 可以看到,耗时由分钟级减至毫秒级,效果还是非常不错的。 732 | 733 | 当然,这毕竟还是一个计算密集型任务,所以可以通过`Web worker`放在独立线程中去执行。 734 | 735 | 736 | 737 | # 间距 738 | 739 | 目前文本之间基本是紧挨着,接下来添加点间距。 740 | 741 | 因为我们是通过检测某个像素点上有没有文字,所有只要在检测阶段让间距的位置上存在内容,最后实际显示文字时是空白,那么就实现了间距的添加。 742 | 743 | 前面获取文字的像素数据时我们是通过`ctx.fillText`来绘制文字,还有一个`strokeText`方法可以用于绘制文字的轮廓,它可以受`lineWidth`属性影响,当`lineWidth`设置的越大,文字线条也越粗,我们就可以通过这个特性来实现间距,只在获取文字的像素数据时设置`lineWidth`,比如设置为10,最终通过`DOM`渲染文字的时候没有这个设置,线宽为1,那么就多了9的间距。 744 | 745 | 这个`lineWidth`怎么设置呢,可以直接写死某个数值,也可以相对于文字的字号: 746 | 747 | ```js 748 | const getTextImageData = (text, fontStyle, space = 0) => { 749 | // 相对于字号的间距 750 | const lineWidth = space * fontStyle.fontSize * 2 751 | let { width, height } = measureText(text, fontStyle, lineWidth) 752 | // 线条变粗了,文字宽高也会变大 753 | width = Math.ceil(width + lineWidth) 754 | height = Math.ceil(height + lineWidth) 755 | // ... 756 | ctx.fillText(text, 0, 0) 757 | //如果要设置间距,则使用strokeText方法绘制文本 758 | if (lineWidth > 0) { 759 | ctx.lineWidth = lineWidth 760 | ctx.strokeText(text, 0, 0) 761 | } 762 | } 763 | ``` 764 | 765 | 线条两侧的间距各为字号的倍数,则总的线宽需要乘2。 766 | 767 | 线条加粗了,文字的宽高也会变大,增加的大小就是间距的大小。 768 | 769 | 最后使用`strokeText`方法绘制文本即可。 770 | 771 | 接下来给文本类添加上间距的属性: 772 | 773 | ```js 774 | // 文本类 775 | class WordItem { 776 | constructor({ text, weight, fontStyle, color, space }) { 777 | // 间距 778 | this.space = space || 0 779 | // 文本像素数据 780 | this.imageData = getTextImageData(text, fontStyle, this.space) 781 | // ... 782 | } 783 | } 784 | ``` 785 | 786 | `WordCloud`同样也加上这个属性,这里就略过了。 787 | 788 | 当`space`设置为`0.5`时的效果如下: 789 | 790 | ![image-20240219151658451](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240219151658451.png) 791 | 792 | 793 | 794 | # 旋转 795 | 796 | 接下来我们让文字支持旋转。 797 | 798 | 首先要修改的是获取文字像素数据的方法,因为`canvas`的大小目前是根据文字的宽高设置的,当文字旋转后显然就不行了: 799 | 800 | ![image-20240219095637550](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240219095637550.png) 801 | 802 | 如图所示,绿色的是文字未旋转时的包围框,当文字旋转后,我们需要的是红色的包围框,那么问题就转换成了如何根据文字的宽高和旋转角度计算出旋转后的文字的包围框。 803 | 804 | 这个计算也很简单,只需要用到最简单的三角函数即可。 805 | 806 | ![image-20240219102834306](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240219102834306.png) 807 | 808 | 宽度的计算可以参考上图,因为文字是一个矩形,不是一条线,所以需要两段长度相加: 809 | 810 | ```js 811 | width * Math.cos(r) + height * Math.sin(r) 812 | ``` 813 | 814 | 高度的计算也是一样的: 815 | 816 | ![image-20240219103053320](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240219103053320.png) 817 | 818 | ```js 819 | width * Math.sin(rad) + height * Math.cos(rad) 820 | ``` 821 | 822 | 由此我们可以得到如下的函数: 823 | 824 | ```js 825 | // 计算旋转后的矩形的宽高 826 | const getRotateBoundingRect = (width, height, rotate) => { 827 | const rad = degToRad(rotate) 828 | const w = width * Math.abs(Math.cos(rad)) + height * Math.abs(Math.sin(rad)) 829 | const h = width * Math.abs(Math.sin(rad)) + height * Math.abs(Math.cos(rad)) 830 | return { 831 | width: Math.ceil(w), 832 | height: Math.ceil(h) 833 | } 834 | } 835 | 836 | // 角度转弧度 837 | const degToRad = deg => { 838 | return (deg * Math.PI) / 180 839 | } 840 | ``` 841 | 842 | 因为三角函数计算出来可能是负数,但是宽高总不能是负的,所以需要转成正数。 843 | 844 | 那么我们就可以在`getTextImageData`方法中使用这个函数了: 845 | 846 | ```js 847 | // 获取文字的像素点数据 848 | const getTextImageData = (text, fontStyle, rotate = 0) => { 849 | // ... 850 | const rect = getRotateBoundingRect( 851 | width + lineWidth, 852 | height + lineWidth, 853 | rotate 854 | ) 855 | width = rect.width 856 | height = rect.height 857 | canvas.width = width 858 | canvas.height = height 859 | // ... 860 | // 绘制文本 861 | ctx.translate(width / 2, height / 2) 862 | ctx.rotate(degToRad(rotate)) 863 | // ... 864 | } 865 | ``` 866 | 867 | 不要忘了通过`rotate`方法旋转文字。 868 | 869 | 因为我们的检测是基于像素的,所以文字具体怎么旋转其实都无所谓,那么像素检测过程无需修改。 870 | 871 | 现在来给文本类添加一个角度属性: 872 | 873 | ```js 874 | // 文本类 875 | class WordItem { 876 | constructor({ text, weight, fontStyle, color, rotate }) { 877 | // ... 878 | // 旋转角度 879 | this.rotate = rotate 880 | // ... 881 | // 文本像素数据 882 | this.imageData = getTextImageData(text, fontStyle, this.space, this.rotate) 883 | // ... 884 | } 885 | } 886 | ``` 887 | 888 | 然后在返回计算结果的地方也加上角度: 889 | 890 | ```js 891 | class WordCloud { 892 | run(words = [], done = () => {}) { 893 | // ... 894 | const res = wordItemList.map(item => { 895 | return { 896 | // ... 897 | rotate: item.rotate 898 | } 899 | }) 900 | done(res) 901 | } 902 | } 903 | ``` 904 | 905 | 最后,渲染时加上旋转的样式就可以了: 906 | 907 | ```html 908 |
917 | {{ item.text }} 918 |
919 | ``` 920 | 921 | 来看看效果: 922 | 923 | ![image-20240219153642854](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240219153642854.png) 924 | 925 | 可以看到很多文字都重叠了,这是为什么呢,首先自信一点,位置计算肯定是没有问题的,那么问题只能出在最后的显示上,仔细思考就会发现,我们计算出来的位置是文本包围框的左上角,但是最后用`css`设置文本旋转时位置就不对了,我们可以在每个文本计算出来的位置上渲染一个小圆点,就可以比较直观的看出差距: 926 | 927 | ![image-20240219154856980](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240219154856980.png) 928 | 929 | 比如对于文本`网易46`,它的实际渲染的位置应该如下图所示才对: 930 | 931 | ![image-20240219155018087](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240219155018087.png) 932 | 933 | 解决这个问题可以通过修改DOM结构及样式。我们给`wordItem`元素外面再套一个元素,作为文本包围框,宽高设置为文本包围框的宽高,然后让`wordItem`元素在该元素中水平和垂直居中即可。 934 | 935 | 首先给文本类添加两个属性: 936 | 937 | ```js 938 | // 文本类 939 | class WordItem { 940 | constructor({ text, weight, fontStyle, color, space, rotate }) { 941 | // 文本像素数据 942 | this.imageData = getTextImageData(text, fontStyle, this.space, this.rotate) 943 | // 文本包围框的宽高 944 | this.width = this.imageData.width 945 | this.height = this.imageData.height 946 | } 947 | } 948 | ``` 949 | 950 | 然后不要忘了在适配容器大小方法中也需要调整这个宽高: 951 | 952 | ```js 953 | class WordCloud { 954 | fitContainer(wordItemList) { 955 | // ... 956 | wordItemList.forEach(item => { 957 | // ... 958 | item.width *= scale 959 | item.height *= scale 960 | item.fontStyle.fontSize *= scale 961 | }) 962 | } 963 | } 964 | ``` 965 | 966 | DOM结构调整为如下: 967 | 968 | ```html 969 |
970 |
981 |
991 | {{ item.text }} 992 |
993 |
994 |
995 | ``` 996 | 997 | ```less 998 | .wordItemWrap { 999 | position: absolute; 1000 | display: flex; 1001 | justify-content: center; 1002 | align-items: center; 1003 | 1004 | .wordItem { 1005 | white-space: nowrap; 1006 | } 1007 | } 1008 | ``` 1009 | 1010 | 现在来看看效果: 1011 | 1012 | ![image-20240219163001205](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240219163001205.png) 1013 | 1014 | # 解决文本超出容器的问题 1015 | 1016 | 有时右侧和下方的文本会超出容器大小,为了方便查看添加一个背景色: 1017 | 1018 | ![image-20240220102353638](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240220102353638.png) 1019 | 1020 | 这是为什么呢,原因可能有两个,一是因为我们获取文本像素时是缩小了文字字号的,导致最后放大后存在偏差;二是最后我们对文本的宽高也进行了缩放,但是文本宽高和文字字号并不完全成正比,导致宽高和实际文字大小不一致。 1021 | 1022 | 解决第二个问题可以通过重新计算文本宽高,我们将获取文本包围框的逻辑由`getTextImageData`方法中提取成一个方法: 1023 | 1024 | ```js 1025 | // 获取文本的外包围框大小 1026 | const getTextBoundingRect = ({ 1027 | text, 1028 | fontStyle, 1029 | space, 1030 | rotate 1031 | } = {}) => { 1032 | const lineWidth = space * fontStyle.fontSize * 2 1033 | // 获取文本的宽高,并向上取整 1034 | const { width, height } = measureText(text, fontStyle) 1035 | const rect = getRotateBoundingRect( 1036 | width + lineWidth, 1037 | height + lineWidth, 1038 | rotate 1039 | ) 1040 | return { 1041 | ...rect, 1042 | lineWidth 1043 | } 1044 | } 1045 | ``` 1046 | 1047 | 然后在`fitContainer`方法中在缩放了文本字号后重新计算文本包围框: 1048 | 1049 | ```js 1050 | class WordCloud { 1051 | fitContainer(wordItemList) { 1052 | wordItemList.forEach(item => { 1053 | // ... 1054 | item.fontStyle.fontSize *= scale 1055 | // 重新计算文本包围框大小而不是直接缩放,因为文本包围框大小和字号并不成正比 1056 | const { width, height } = getTextBoundingRect({ 1057 | text: item.text, 1058 | fontStyle: item.fontStyle, 1059 | space: item.space, 1060 | rotate: item.rotate 1061 | }) 1062 | item.width = width 1063 | item.height = height 1064 | }) 1065 | } 1066 | } 1067 | ``` 1068 | 1069 | 这样下方的文本超出问题就解决了,但是右侧还是会存在问题: 1070 | 1071 | ![image-20240220103025217](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240220103025217.png) 1072 | 1073 | 解决方式也很简单,直接根据文本元素的位置和大小判断是否超出了容器,是的话就调整一下位置: 1074 | 1075 | ```js 1076 | class WordCloud { 1077 | fitContainer(wordItemList) { 1078 | wordItemList.forEach(item => { 1079 | // ... 1080 | item.fontStyle.fontSize *= scale 1081 | // 重新计算文本包围框大小而不是直接缩放,因为文本包围框大小和字号并不成正比 1082 | // ... 1083 | // 修正超出容器文本 1084 | if (item.left + item.width > this.elWidth) { 1085 | item.left = this.elWidth - item.width 1086 | } 1087 | if (item.top + item.height > this.elHeight) { 1088 | item.top = this.elHeight - item.height 1089 | } 1090 | }) 1091 | } 1092 | } 1093 | ``` 1094 | 1095 | 到这里,一个简单的词云效果就完成了: 1096 | 1097 | ![image-20240220103155851](C:\Users\wanglin25\AppData\Roaming\Typora\typora-user-images\image-20240220103155851.png) 1098 | 1099 | 1100 | 1101 | # 总结 1102 | 1103 | 本文详细介绍了如何从零开始实现一个简单的词云效果,实现上部分参考了[VueWordCloud](https://github.com/SeregPie/VueWordCloud)这个项目。 1104 | 1105 | 笔者也封装成了一个简单的库,可以直接调用,感兴趣的可以移步仓库:[https://github.com/wanglin2/simple-word-cloud](https://github.com/wanglin2/simple-word-cloud)。 1106 | 1107 | 1108 | 1109 | 1110 | 1111 | 1112 | 1113 | 1114 | 1115 | 1116 | 1117 | 1118 | 1119 | 1120 | 1121 | 1122 | 1123 | 1124 | 1125 | 1126 | 1127 | 1128 | 1129 | 1130 | 1131 | 1132 | 1133 | 1134 | 1135 | 1136 | 1137 | 1138 | 1139 | 1140 | 1141 | 1142 | 1143 | 1144 | 1145 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 11 | simple-word-cloud 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /docs/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 11 | simple-word-cloud 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "canvas-demos", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --host", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "bezier-easing": "^2.1.0", 13 | "element-plus": "^2.4.1", 14 | "perfect-freehand": "^1.2.0", 15 | "tinycolor2": "^1.6.0", 16 | "vue": "^3.3.4", 17 | "vue-router": "^4.2.5" 18 | }, 19 | "devDependencies": { 20 | "@vitejs/plugin-vue": "^4.2.3", 21 | "less": "^4.2.0", 22 | "less-loader": "^11.1.3", 23 | "vite": "^4.4.5" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /simple-word-cloud/index.js: -------------------------------------------------------------------------------- 1 | import WordItem from './src/WordItem' 2 | import { 3 | getFontSize, 4 | getTextBoundingRect, 5 | createRandom, 6 | joinFontStr, 7 | downloadFile 8 | } from './src/utils' 9 | import { addToMap, getPosition, getBoundingRect, clear } from './src/compute' 10 | 11 | // 词云类 12 | class WordCloud { 13 | constructor({ el, ...rest }) { 14 | // 词云渲染的容器元素 15 | this.el = el 16 | this.updateElSize() 17 | if (this.elWidth <= 0 || this.elHeight <= 0) 18 | throw new Error('容器宽高不能为0') 19 | const elPosition = window.getComputedStyle(this.el).position 20 | if (elPosition === 'static') { 21 | this.el.style.position = 'relative' 22 | } 23 | this.updateOption(rest) 24 | // 文本元素复用列表 25 | this.wordItemElList = [] 26 | // canvas 27 | this.canvas = null 28 | this.renderCtx = null 29 | } 30 | 31 | // 更新容器大小 32 | updateElSize() { 33 | const elRect = this.el.getBoundingClientRect() 34 | this.elWidth = elRect.width 35 | this.elHeight = elRect.height 36 | } 37 | 38 | // 当容器大小改变了需要调用该方法 39 | // 此外,你需要自行再次调用run方法或render方法 40 | resize() { 41 | this.updateElSize() 42 | } 43 | 44 | // 更新配置选项 45 | updateOption({ 46 | minFontSize, 47 | maxFontSize, 48 | fontFamily, 49 | fontWeight, 50 | fontStyle, 51 | fontSizeScale, 52 | rotateType, 53 | space, 54 | colorList, 55 | transition, 56 | smallWeightInCenter, 57 | onClick 58 | }) { 59 | // 字号大小 60 | this.minFontSize = minFontSize || 12 61 | this.maxFontSize = maxFontSize || 40 62 | if (this.maxFontSize < this.minFontSize) 63 | throw new Error('maxFontSize不能小于minFontSize') 64 | // 字体 65 | this.fontFamily = fontFamily || '微软雅黑, Microsoft YaHei' 66 | // 加粗 67 | this.fontWeight = fontWeight || '' 68 | // 斜体 69 | this.fontStyle = fontStyle || '' 70 | // 文字之间的间距,相对于字号,即该值会和字号相乘得到最终的间距 71 | this.space = space || 0 72 | // 文字颜色列表 73 | this.colorList = colorList 74 | // 旋转类型,none(无)、cross(交叉,即要么是无旋转,要么是-90度旋转)、oblique(倾斜,即-45度旋转)、random(随机。即-90度到90度之间),如果要针对某个文本 75 | this.rotateType = rotateType || 'none' 76 | // 文字整体的缩小比例,用于加快计算速度,一般是0-1之间的小数 77 | this.fontSizeScale = fontSizeScale || 1 / this.minFontSize 78 | // 文本元素过渡动画 79 | this.transition = transition || 'all 0.5s ease' 80 | // 按权重从小到大的顺序渲染,默认是按权重从大到小进行渲染 81 | this.smallWeightInCenter = smallWeightInCenter || false 82 | // 点击事件 83 | this.onClick = onClick || null 84 | // 当前渲染的列表 85 | this.curRenderList = [] 86 | } 87 | 88 | // 创建旋转角度 89 | createRotate() { 90 | switch (this.rotateType) { 91 | case 'cross': 92 | return Math.random() > 0.5 ? -90 : 0 93 | case 'oblique': 94 | return -45 95 | case 'random': 96 | return createRandom(-90, 90) 97 | default: 98 | return 0 99 | } 100 | } 101 | 102 | // 计算词云位置 103 | run(words = [], done = () => {}) { 104 | clear() 105 | // 按权重从大到小排序 106 | const wordList = [...words].sort((a, b) => { 107 | return this.smallWeightInCenter ? a[1] - b[1] : b[1] - a[1] 108 | }) 109 | let minWeight = wordList[wordList.length - 1][1] 110 | let maxWeight = wordList[0][1] 111 | if (this.smallWeightInCenter) { 112 | const tmp = minWeight 113 | minWeight = maxWeight 114 | maxWeight = tmp 115 | } 116 | // 创建词云文本实例 117 | const wordItemList = wordList.map(item => { 118 | const text = item[0] 119 | const weight = item[1] 120 | const config = item[2] || {} 121 | // 旋转角度 122 | let rotate = 0 123 | if (!Number.isNaN(Number(config.rotate))) { 124 | rotate = Number(config.rotate) 125 | } else { 126 | rotate = this.createRotate() 127 | } 128 | return new WordItem({ 129 | text, 130 | weight, 131 | space: config.space || this.space, 132 | rotate, 133 | color: config.color, 134 | colorList: this.colorList, 135 | fontStyle: { 136 | fontSize: 137 | getFontSize( 138 | weight, 139 | minWeight, 140 | maxWeight, 141 | this.minFontSize, 142 | this.maxFontSize 143 | ) * this.fontSizeScale, 144 | fontFamily: config.fontFamily || this.fontFamily, 145 | fontWeight: config.fontWeight || this.fontWeight, 146 | fontStyle: config.fontStyle || this.fontStyle 147 | } 148 | }) 149 | }) 150 | this.compute(wordItemList) 151 | this.fitContainer(wordItemList) 152 | done(wordItemList) 153 | } 154 | 155 | // 计算并使用canvas渲染到容器内 156 | renderUseCanvas(words, done = () => {}) { 157 | if (!this.canvas) { 158 | this.canvas = document.createElement('canvas') 159 | this.canvas.width = this.elWidth 160 | this.canvas.height = this.elHeight 161 | this.el.appendChild(this.canvas) 162 | this.renderCtx = this.canvas.getContext('2d') 163 | this.canvas.addEventListener('click', e => { 164 | this.onCanvasClick(e) 165 | }) 166 | } 167 | this.renderCtx.clearRect(0, 0, this.elWidth, this.elHeight) 168 | this.run(words, list => { 169 | this.curRenderList = list 170 | list.forEach(item => { 171 | this.renderCtx.save() 172 | this.renderCtx.font = joinFontStr(item.fontStyle) 173 | this.renderCtx.fillStyle = item.color 174 | if (item.rotate === 0) { 175 | this.renderCtx.textBaseline = 'top' 176 | this.renderCtx.fillText(item.text, item.left, item.top) 177 | } else { 178 | const cx = item.left + item.width / 2 179 | const cy = item.top + item.height / 2 180 | this.renderCtx.translate(cx, cy) 181 | this.renderCtx.textAlign = 'center' 182 | this.renderCtx.textBaseline = 'middle' 183 | this.renderCtx.rotate((item.rotate * Math.PI) / 180) 184 | this.renderCtx.fillText(item.text, 0, 0) 185 | } 186 | this.renderCtx.restore() 187 | }) 188 | done(list) 189 | }) 190 | } 191 | 192 | // Canvas的点击事件 193 | onCanvasClick(e) { 194 | const { left, top } = this.canvas.getBoundingClientRect() 195 | const x = e.clientX - left 196 | const y = e.clientY - top 197 | let res = null 198 | for (let i = 0; i < this.curRenderList.length; i++) { 199 | const item = this.curRenderList[i] 200 | this.renderCtx.save() 201 | this.renderCtx.font = joinFontStr(item.fontStyle) 202 | this.renderCtx.fillStyle = item.color 203 | this.renderCtx.textBaseline = 'top' 204 | this.renderCtx.beginPath() 205 | if (item.rotate === 0) { 206 | this.renderCtx.rect(item.left, item.top, item.width, item.height) 207 | } else { 208 | const textSize = getTextBoundingRect({ 209 | text: item.text, 210 | fontStyle: item.fontStyle, 211 | space: item.space 212 | }) 213 | const cx = item.left + item.width / 2 214 | const cy = item.top + item.height / 2 215 | this.renderCtx.translate(cx, cy) 216 | this.renderCtx.rotate((item.rotate * Math.PI) / 180) 217 | this.renderCtx.rect( 218 | -textSize.width / 2, 219 | -textSize.height / 2, 220 | textSize.width, 221 | textSize.height 222 | ) 223 | } 224 | this.renderCtx.closePath() 225 | this.renderCtx.restore() 226 | const isIn = this.renderCtx.isPointInPath(x, y) 227 | if (isIn) { 228 | res = item 229 | break 230 | } 231 | } 232 | if (res && this.onClick) { 233 | this.onClick(res) 234 | } 235 | } 236 | 237 | // 导出画布,只有当使用renderUseCanvas方法渲染时才有效 238 | // isDownload:是否直接触发下载,为false则返回data:URL数据 239 | exportCanvas(isDownload = true, fileName = 'wordCloud') { 240 | if (!this.canvas) return null 241 | const res = this.canvas.toDataURL() 242 | if (isDownload) { 243 | downloadFile(res, fileName) 244 | } else { 245 | return res 246 | } 247 | } 248 | 249 | // 计算并使用DOM直接渲染到容器内 250 | render(words, done = () => {}) { 251 | this.run(words, list => { 252 | this.curRenderList = [] 253 | list.forEach((item, index) => { 254 | const exist = this.wordItemElList[index] 255 | let wrap = null 256 | let inner = null 257 | if (exist) { 258 | wrap = exist.wrap 259 | inner = exist.inner 260 | } else { 261 | wrap = document.createElement('div') 262 | wrap.className = 'simpleWordCloudWordItemWrap' 263 | wrap.style.cssText = ` 264 | position: absolute; 265 | display: flex; 266 | justify-content: center; 267 | align-items: center; 268 | left: ${this.elWidth / 2}px; 269 | top: ${this.elHeight / 2}px; 270 | ` 271 | inner = document.createElement('div') 272 | inner.className = 'simpleWordCloudWordItemInner' 273 | inner.style.cssText = ` 274 | white-space: nowrap; 275 | ` 276 | wrap.appendChild(inner) 277 | this.wordItemElList.push({ 278 | wrap, 279 | inner 280 | }) 281 | wrap.addEventListener('click', () => { 282 | if (this.onClick) this.onClick(item) 283 | }) 284 | this.el.appendChild(wrap) 285 | } 286 | setTimeout(() => { 287 | wrap.style.left = `${item.left}px` 288 | wrap.style.top = `${item.top}px` 289 | wrap.style.width = `${item.width}px` 290 | wrap.style.height = `${item.height}px` 291 | wrap.style.transition = this.transition 292 | 293 | inner.style.fontSize = `${item.fontStyle.fontSize}px` 294 | inner.style.fontFamily = `${item.fontStyle.fontFamily}` 295 | inner.style.fontWeight = `${item.fontStyle.fontWeight}` 296 | inner.style.color = `${item.color}` 297 | inner.style.transform = `rotate(${item.rotate}deg)` 298 | inner.style.fontStyle = item.fontStyle.fontStyle 299 | inner.textContent = item.text 300 | }, 0) 301 | }) 302 | // 删除多余的元素 303 | if (this.wordItemElList.length > list.length) { 304 | const wordItemElList = [...this.wordItemElList] 305 | for (let i = wordItemElList.length - 1; i >= list.length; i--) { 306 | this.el.removeChild(wordItemElList[i].wrap) 307 | this.wordItemElList.splice(i, 1) 308 | } 309 | } 310 | done(list) 311 | }) 312 | } 313 | 314 | // 清除渲染 315 | clear() { 316 | this.curRenderList = [] 317 | if (this.canvas) { 318 | this.el.removeChild(this.canvas) 319 | this.canvas = null 320 | this.renderCtx = null 321 | } else { 322 | this.el.innerHTML = '' 323 | this.wordItemElList = [] 324 | } 325 | } 326 | 327 | // 计算文本的位置 328 | compute(wordItemList) { 329 | for (let i = 0; i < wordItemList.length; i++) { 330 | const curWordItem = wordItemList[i] 331 | // 将第一个文本的像素数据保存到map中 332 | if (i === 0) { 333 | addToMap(curWordItem) 334 | continue 335 | } 336 | // 依次计算后续的每个文本的显示位置 337 | const res = getPosition({ 338 | curWordItem, 339 | elWidth: this.elWidth, 340 | elHeight: this.elHeight 341 | }) 342 | curWordItem.left = res[0] 343 | curWordItem.top = res[1] 344 | // 计算出位置后的每个文本也需要将像素数据保存到map中 345 | addToMap(curWordItem) 346 | } 347 | } 348 | 349 | // 根据容器大小调整字号 350 | fitContainer(wordItemList) { 351 | const elRatio = this.elWidth / this.elHeight 352 | let { width, height, left, top } = getBoundingRect() 353 | const wordCloudRatio = width / height 354 | let w, h 355 | let offsetX = 0, 356 | offsetY = 0 357 | if (elRatio > wordCloudRatio) { 358 | // 词云高度以容器高度为准,宽度根据原比例进行缩放 359 | h = this.elHeight 360 | w = wordCloudRatio * this.elHeight 361 | } else { 362 | // 词云宽度以容器宽度为准,高度根据原比例进行缩放 363 | w = this.elWidth 364 | h = this.elWidth / wordCloudRatio 365 | } 366 | const scale = w / width 367 | // 将词云移动到容器中间 368 | left *= scale 369 | top *= scale 370 | if (elRatio > wordCloudRatio) { 371 | offsetY = -top 372 | offsetX = -left + (this.elWidth - w) / 2 373 | } else { 374 | offsetX = -left 375 | offsetY = -top + (this.elHeight - h) / 2 376 | } 377 | wordItemList.forEach(item => { 378 | item.left *= scale 379 | item.top *= scale 380 | item.left += offsetX 381 | item.top += offsetY 382 | item.fontStyle.fontSize *= scale 383 | 384 | // 重新计算文本包围框大小而不是直接缩放,因为文本包围框大小和字号并不成正比 385 | const { width, height } = getTextBoundingRect({ 386 | text: item.text, 387 | fontStyle: item.fontStyle, 388 | space: item.space, 389 | rotate: item.rotate 390 | }) 391 | item.width = width 392 | item.height = height 393 | 394 | // 修正超出容器文本 395 | if (item.left + item.width > this.elWidth) { 396 | item.left = this.elWidth - item.width 397 | } 398 | if (item.top + item.height > this.elHeight) { 399 | item.top = this.elHeight - item.height 400 | } 401 | }) 402 | } 403 | } 404 | 405 | export default WordCloud 406 | -------------------------------------------------------------------------------- /simple-word-cloud/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-word-cloud", 3 | "version": "1.0.1", 4 | "description": "一个简单的词云库", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC" 11 | } 12 | -------------------------------------------------------------------------------- /simple-word-cloud/src/WordItem.js: -------------------------------------------------------------------------------- 1 | import { getTextImageData, getColor } from './utils' 2 | 3 | // 文本类 4 | class WordItem { 5 | constructor({ text, weight, fontStyle, color, space, rotate, colorList }) { 6 | // 文本 7 | this.text = text 8 | // 权重 9 | this.weight = weight 10 | // 字体样式 11 | this.fontStyle = fontStyle 12 | // 文本颜色 13 | this.color = color || getColor(colorList) 14 | // 间距 15 | this.space = space || 0 16 | // 旋转角度 17 | this.rotate = rotate || 0 18 | // 文本像素数据 19 | this.imageData = getTextImageData({ 20 | text, 21 | fontStyle, 22 | space: this.space, 23 | rotate: this.rotate 24 | }) 25 | // 文本包围框的宽高 26 | this.width = this.imageData.width 27 | this.height = this.imageData.height 28 | // 文本渲染的位置 29 | this.left = 0 30 | this.top = 0 31 | } 32 | } 33 | 34 | export default WordItem 35 | -------------------------------------------------------------------------------- /simple-word-cloud/src/compute.js: -------------------------------------------------------------------------------- 1 | let pxMap = {} 2 | let centerX = -1 3 | let centerY = -1 4 | let left = Infinity 5 | let right = -Infinity 6 | let top = Infinity 7 | let bottom = -Infinity 8 | 9 | // 清空状态 10 | export const clear = () => { 11 | pxMap = {} 12 | centerX = -1 13 | centerY = -1 14 | left = Infinity 15 | right = -Infinity 16 | top = Infinity 17 | bottom = -Infinity 18 | } 19 | 20 | // 保存每个文本的像素数据 21 | export const addToMap = curWordItem => { 22 | curWordItem.imageData.data.forEach(item => { 23 | const x = item[0] + curWordItem.left 24 | const y = item[1] + curWordItem.top 25 | pxMap[x + '|' + y] = true 26 | // 更新边界 27 | left = Math.min(left, x) 28 | right = Math.max(right, x) 29 | top = Math.min(top, y) 30 | bottom = Math.max(bottom, y) 31 | }) 32 | // 记录第一个文本的中心点位置 33 | if (centerX === -1 && centerY === -1) { 34 | centerX = Math.floor(curWordItem.imageData.width / 2) 35 | centerY = Math.floor(curWordItem.imageData.height / 2) 36 | } 37 | } 38 | 39 | // 获取边界数据 40 | export const getBoundingRect = () => { 41 | return { 42 | left, 43 | right, 44 | top, 45 | bottom, 46 | width: right - left, 47 | height: bottom - top 48 | } 49 | } 50 | 51 | // 计算文本渲染位置 52 | export const getPosition = ({ elWidth, elHeight, curWordItem }) => { 53 | let startX, endX, startY, endY 54 | // 第一个文本的中心点 55 | startX = endX = centerX 56 | startY = endY = centerY 57 | 58 | // 根据容器的宽高来计算扩散步长 59 | let stepLeft = 1, 60 | stepTop = 1 61 | if (elWidth > elHeight) { 62 | stepLeft = 1 63 | stepTop = elHeight / elWidth 64 | } else if (elHeight > elWidth) { 65 | stepTop = 1 66 | stepLeft = elWidth / elHeight 67 | } 68 | 69 | if (canFit(curWordItem, [startX, startY])) { 70 | return [startX, startY] 71 | } 72 | // 依次扩散遍历每个像素点 73 | while (true) { 74 | // 向下取整作为当前比较的值 75 | const curStartX = Math.floor(startX) 76 | const curStartY = Math.floor(startY) 77 | const curEndX = Math.floor(endX) 78 | const curEndY = Math.floor(endY) 79 | 80 | // 遍历矩形右侧的边 81 | for (let top = curStartY; top < curEndY; ++top) { 82 | const value = [curEndX, top] 83 | if (canFit(curWordItem, value)) { 84 | return value 85 | } 86 | } 87 | // 遍历矩形下面的边 88 | for (let left = curEndX; left > curStartX; --left) { 89 | const value = [left, curEndY] 90 | if (canFit(curWordItem, value)) { 91 | return value 92 | } 93 | } 94 | // 遍历矩形左侧的边 95 | for (let top = curEndY; top > curStartY; --top) { 96 | const value = [curStartX, top] 97 | if (canFit(curWordItem, value)) { 98 | return value 99 | } 100 | } 101 | // 遍历矩形上面的边 102 | for (let left = curStartX; left < curEndX; ++left) { 103 | const value = [left, curStartY] 104 | if (canFit(curWordItem, value)) { 105 | return value 106 | } 107 | } 108 | // 向四周扩散 109 | startX -= stepLeft 110 | endX += stepLeft 111 | startY -= stepTop 112 | endY += stepTop 113 | } 114 | } 115 | 116 | // 判断某个像素点所在位置能否完全容纳某个文本 117 | const canFit = (curWordItem, [cx, cy]) => { 118 | if (pxMap[`${cx}|${cy}`]) return false 119 | return curWordItem.imageData.data.every(([x, y]) => { 120 | const left = x + cx 121 | const top = y + cy 122 | return !pxMap[`${left}|${top}`] 123 | }) 124 | } -------------------------------------------------------------------------------- /simple-word-cloud/src/utils.js: -------------------------------------------------------------------------------- 1 | // 获取随机颜色 2 | const colorList = [ 3 | '#326BFF', 4 | '#5C27FE', 5 | '#C165DD', 6 | '#FACD68', 7 | '#FC76B3', 8 | '#1DE5E2', 9 | '#B588F7', 10 | '#08C792', 11 | '#FF7B02', 12 | '#3bc4c7', 13 | '#3a9eea', 14 | '#461e47', 15 | '#ff4e69' 16 | ] 17 | export const getColor = (list = colorList) => { 18 | return list[Math.floor(Math.random() * list.length)] 19 | } 20 | 21 | // 拼接font字符串 22 | export const joinFontStr = ({ 23 | fontSize, 24 | fontFamily, 25 | fontWeight, 26 | fontStyle 27 | }) => { 28 | return `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily} ` 29 | } 30 | 31 | //计算文本宽高 32 | let measureTextContext = null 33 | export const measureText = (text, fontStyle) => { 34 | // 创建一个canvas用于测量 35 | if (!measureTextContext) { 36 | const canvas = document.createElement('canvas') 37 | measureTextContext = canvas.getContext('2d') 38 | } 39 | measureTextContext.save() 40 | // 设置文本样式 41 | measureTextContext.font = joinFontStr(fontStyle) 42 | // 测量文本 43 | const { width, actualBoundingBoxAscent, actualBoundingBoxDescent } = 44 | measureTextContext.measureText(text) 45 | measureTextContext.restore() 46 | // 返回文本宽高 47 | const height = actualBoundingBoxAscent + actualBoundingBoxDescent 48 | return { width, height } 49 | } 50 | 51 | // 获取文本的外包围框大小 52 | export const getTextBoundingRect = ({ 53 | text, 54 | fontStyle, 55 | space, 56 | rotate 57 | } = {}) => { 58 | const lineWidth = space * fontStyle.fontSize * 2 59 | // 获取文本的宽高,并向上取整 60 | const { width, height } = measureText(text, fontStyle) 61 | const rect = getRotateBoundingRect( 62 | width + lineWidth, 63 | height + lineWidth, 64 | rotate 65 | ) 66 | return { 67 | ...rect, 68 | lineWidth 69 | } 70 | } 71 | 72 | // 获取文字的像素点数据 73 | export const getTextImageData = ({ text, fontStyle, space, rotate }) => { 74 | space = space || 0 75 | rotate = rotate || 0 76 | const canvas = document.createElement('canvas') 77 | const { lineWidth, width, height } = getTextBoundingRect({ 78 | text, 79 | fontStyle, 80 | space, 81 | rotate 82 | }) 83 | canvas.width = width 84 | canvas.height = height 85 | const ctx = canvas.getContext('2d') 86 | // 绘制文本 87 | ctx.translate(width / 2, height / 2) 88 | ctx.rotate(degToRad(rotate)) 89 | ctx.font = joinFontStr(fontStyle) 90 | ctx.textAlign = 'center' 91 | ctx.textBaseline = 'middle' 92 | ctx.fillText(text, 0, 0) 93 | if (lineWidth > 0) { 94 | ctx.lineWidth = lineWidth 95 | ctx.strokeText(text, 0, 0) 96 | } 97 | // 获取画布的像素数据 98 | const image = ctx.getImageData(0, 0, width, height).data 99 | // 遍历每个像素点,找出有内容的像素点 100 | const imageData = [] 101 | for (let x = 0; x < width; x++) { 102 | for (let y = 0; y < height; y++) { 103 | // 如果a通道不为0,那么代表该像素点存在内容 104 | const a = image[x * 4 + y * (width * 4) + 3] 105 | if (a > 0) { 106 | imageData.push([x, y]) 107 | } 108 | } 109 | } 110 | return { 111 | data: imageData, 112 | width, 113 | height 114 | } 115 | } 116 | 117 | // 根据权重计算字号 118 | export const getFontSize = ( 119 | weight, 120 | minWeight, 121 | maxWeight, 122 | minFontSize, 123 | maxFontSize 124 | ) => { 125 | return ( 126 | minFontSize + 127 | ((weight - minWeight) / (maxWeight - minWeight)) * 128 | (maxFontSize - minFontSize) 129 | ) 130 | } 131 | 132 | // 计算旋转后的矩形的宽高 133 | export const getRotateBoundingRect = (width, height, rotate = 0) => { 134 | const rad = degToRad(rotate) 135 | const w = width * Math.abs(Math.cos(rad)) + height * Math.abs(Math.sin(rad)) 136 | const h = width * Math.abs(Math.sin(rad)) + height * Math.abs(Math.cos(rad)) 137 | return { 138 | width: Math.ceil(w), 139 | height: Math.ceil(h) 140 | } 141 | } 142 | 143 | // 角度转弧度 144 | export const degToRad = deg => { 145 | return (deg * Math.PI) / 180 146 | } 147 | 148 | // 返回一个随机整数 149 | export const createRandom = (min, max) => { 150 | return min + Math.floor(Math.random() * (max - min)) 151 | } 152 | 153 | // 下载文件 154 | export const downloadFile = (file, fileName) => { 155 | let a = document.createElement('a') 156 | a.href = file 157 | a.download = fileName 158 | a.click() 159 | } 160 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 95 | 96 | 217 | 218 | 307 | -------------------------------------------------------------------------------- /src/constant.js: -------------------------------------------------------------------------------- 1 | export const fontFamilyList = [ 2 | '微软雅黑, Microsoft YaHei', 3 | '宋体, SimSun, Songti SC', 4 | '楷体, 楷体_GB2312, SimKai, STKaiti', 5 | '黑体, SimHei, Heiti SC', 6 | '隶书, SimLi', 7 | 'Abril Fatface', 8 | 'Annie Use Your Telescope', 9 | 'Anton', 10 | 'Bahiana', 11 | 'Baloo Bhaijaan', 12 | 'Barrio', 13 | 'Finger Paint', 14 | 'Fredericka the Great', 15 | 'Gloria Hallelujah', 16 | 'Indie Flower', 17 | 'Life Savers', 18 | 'Londrina Sketch', 19 | 'Love Ya Like A Sister', 20 | 'Merienda', 21 | 'Nothing You Could Do', 22 | 'Pacifico', 23 | 'Quicksand', 24 | 'Righteous', 25 | 'Roboto', 26 | 'Sacramento', 27 | 'Shadows Into Light', 28 | 'andale mono', 29 | 'arial, helvetica, sans-serif', 30 | 'arial black, avant garde', 31 | 'comic sans ms', 32 | 'impact, chicago', 33 | 'times new roman', 34 | 'sans-serif', 35 | 'serif', 36 | 'Lato', 37 | 'Montserrat', 38 | 'Oswald', 39 | 'Source Sans Pro', 40 | 'Merriweather', 41 | 'Concert One', 42 | 'Long Cang' 43 | ] 44 | 45 | export const colorsList = [ 46 | [ 47 | '#326BFF', 48 | '#5C27FE', 49 | '#C165DD', 50 | '#FACD68', 51 | '#FC76B3', 52 | '#1DE5E2', 53 | '#B588F7', 54 | '#08C792', 55 | '#FF7B02', 56 | '#3bc4c7', 57 | '#3a9eea', 58 | '#461e47', 59 | '#ff4e69' 60 | ], 61 | ['#d99cd1', '#c99cd1', '#b99cd1', '#a99cd1'], 62 | ['#403030', '#f97a7a'], 63 | ['#31a50d', '#d1b022', '#74482a'], 64 | ['#ffd077', '#3bc4c7', '#3a9eea', '#ff4e69', '#461e47'] 65 | ] 66 | 67 | export const transitionList = [ 68 | 'ease', 69 | 'linear', 70 | 'ease-in', 71 | 'ease-out', 72 | 'ease-in-out', 73 | 'cubic-bezier(0.1,0.7,1.0,0.1)' 74 | ] 75 | -------------------------------------------------------------------------------- /src/example.js: -------------------------------------------------------------------------------- 1 | let words = [ 2 | '海康威视', 3 | '字节跳动', 4 | '腾讯', 5 | '阿里巴巴', 6 | '美团', 7 | '百度', 8 | '京东', 9 | '网易', 10 | '滴滴出行', 11 | '小米', 12 | '哔哩哔哩', 13 | '快手', 14 | '新浪', 15 | '360', 16 | '爱奇艺', 17 | '搜狐', 18 | '搜狗', 19 | '58同城', 20 | '高德', 21 | '完美世界', 22 | '豆瓣', 23 | '去哪儿网', 24 | '知乎', 25 | '当当网', 26 | '雪球', 27 | '商汤科技', 28 | '好未来', 29 | '罗辑思维', 30 | '唱吧', 31 | '作业帮', 32 | '值得买', 33 | '猿辅导', 34 | '每日优鲜', 35 | '寒武纪', 36 | '微软', 37 | '亚马逊', 38 | 'IBM', 39 | '拼多多', 40 | '哔哩哔哩', 41 | '饿了么', 42 | '盒马', 43 | '趣头条', 44 | '携程', 45 | '陆金所', 46 | '哈啰出行', 47 | '阅文', 48 | '喜马拉雅', 49 | '叮咚买菜', 50 | '虎扑网', 51 | '腾讯', 52 | '顺丰', 53 | '华为', 54 | '中兴', 55 | '大疆', 56 | '迅雷', 57 | '微众银行', 58 | '微信', 59 | '唯品会', 60 | '酷狗音乐', 61 | '小鹏汽车', 62 | '荔枝FM', 63 | '三七互娱', 64 | '阿里巴巴', 65 | '阿里云', 66 | '蚂蚁金服', 67 | '菜鸟', 68 | '花瓣', 69 | '同花顺', 70 | 'google', 71 | 'Apple', 72 | 'Microsoft', 73 | 'Amazon', 74 | 'Facebook', 75 | 'Oracle', 76 | 'Netflix', 77 | 'Reddit', 78 | 'Yelp', 79 | 'adobe', 80 | 'nasdaq', 81 | 'hewlett' 82 | ] 83 | // words = words.slice(0, 20) 84 | words = words.slice(0, words.length) 85 | 86 | const data = words.map(item => { 87 | const weight = Math.floor(Math.random() * 100) 88 | return [item, weight] 89 | }) 90 | 91 | export default data 92 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import './style.css' 3 | import App from './App.vue' 4 | import ElementPlus from 'element-plus' 5 | import 'element-plus/dist/index.css' 6 | 7 | const app = createApp(App) 8 | 9 | app.use(ElementPlus) 10 | app.mount('#app') 11 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | html, body, #app { 8 | width: 100%; 9 | height: 100%; 10 | } -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | base: './', 7 | build: { 8 | outDir: 'docs', 9 | }, 10 | plugins: [vue()], 11 | }) 12 | --------------------------------------------------------------------------------