关于前端:如何从零实现一个词云效果

7次阅读

共计 17309 个字符,预计需要花费 44 分钟才能阅读完成。

词云是一种文本数据的可视化模式,它富裕表现力,通过大小不一,五光十色,随机紧挨在一起的文本模式,能够在泛滥文本中直观地突出呈现频率较高的关键词,给予视觉上的突出,从而过滤掉大量的文本信息,在理论我的项目中,咱们能够抉择应用 wordcloud2、VueWordCloud 等开源库来实现,然而你有没有好奇过它是怎么实现的呢,本文会尝试从 0 实现一个简略的词云成果。

最终成果领先看:https://wanglin2.github.io/simple-word-cloud/。

基本原理

词云的根本实现原理非常简单,就是通过遍历像素点进行判断,咱们能够顺次遍历每个文本的每个像素点,而后再顺次扫描以后画布的每个像素点,而后判断这个像素点的地位是否包容以后文本,也就是不会和曾经存在的文本重叠,如果能够的话这个像素点的地位就是该文本显示的地位。

获取文本的像素点咱们能够通过 canvasgetImageData办法。

最终渲染你能够间接应用canvas,也能够应用DOM,本文会抉择应用DOM,因为能够更不便的批改内容、款式以及增加交互事件。

计算文字大小

如果咱们接管的源数据结构如下所示:

const words = [['字节跳动', 33],
    ['腾讯', 21],
    ['阿里巴巴', 4],
    ['美团', 56],
]

每个数组的第一项代表文本,第二项代表该文本所对应的权重大小,权重越大,在词云图中渲染时的字号也越大。

那么怎么依据这个权重来计算出所对应的文字大小呢,首先咱们能够找出所有文本中权重的最大值和最小值,那么就能够失去权重的区间,而后将每个文本的权重减去最小的权重,除以总的区间,就能够失去这个文本的权重在总的区间中的所占比例,同时,咱们须要设置词云图字号容许的最小值和最大值,那么只有和字号的区间相乘,就能够失去权重对应的字号大小,基于此咱们能够写出以下函数:

// 依据权重计算字号
const getFontSize = (
    weight,
    minWeight,
    maxWeight,
    minFontSize,
    maxFontSize
) => {
    const weightRange = maxWeight - minWeight
    const fontSizeRange = maxFontSize - minFontSize
    const curWeightRange = weight - minWeight
    return minFontSize + (curWeightRange / weightRange) * fontSizeRange
}

获取文本的像素数据

canvas有一个 getImageData 办法能够获取画布的像素数据,那么咱们就能够将文本在 canvas 上绘制进去,而后再调用该办法就能失去文本的像素数据了。

文本的字体款式不同,绘制进去的文本也不一样,所以绘制前须要设置一下字体的各种属性,比方字号、字体、加粗、斜体等等,能够通过绘图上下文的 font 属性来设置,本文简略起见,只反对字号、字体、加粗三个字体属性。

因为 canvas 不像 css 一样反对单个属性进行设置,所以咱们写一个工具办法来拼接字体款式:

// 拼接 font 字符串
const joinFontStr = ({fontSize, fontFamily, fontWeight}) => {return `${fontWeight} ${fontSize}px ${fontFamily} `
}

接下来还要思考的一个问题是 canvas 的大小是多少,很显著,只有能包容文本就够了,所以也就是文本的大小,canvas同样也提供了测量文本大小的办法measureText,那么咱们能够写出如下的工具办法:

// 获取文本宽高
let measureTextContext = null
const measureText = (text, fontStyle) => {
    // 创立一个 canvas 用于测量
    if (!measureTextContext) {const canvas = document.createElement('canvas')
        measureTextContext = canvas.getContext('2d')
    }
    measureTextContext.save()
    // 设置字体款式
    measureTextContext.font = joinFontStr(fontStyle)
    // 测量文本
    const {width, actualBoundingBoxAscent, actualBoundingBoxDescent} =
          measureTextContext.measureText(text)
    measureTextContext.restore()
    // 返回文本宽高
    const height = actualBoundingBoxAscent + actualBoundingBoxDescent
    return {width, height}
}

measureText办法不会间接返回高度,所以咱们要通过返回的其余属性计算得出,对于 measureText 更具体的介绍能够参考 measureText。

有了以上两个办法,咱们就能够写出如下的办法来获取文本的像素数据:

// 获取文字的像素点数据
export const getTextImageData = (text, fontStyle) => {const canvas = document.createElement('canvas')
    // 获取文本的宽高,并向上取整
    let {width, height} = measureText(text, fontStyle)
    width = Math.ceil(width)
    height = Math.ceil(height)
    canvas.width = width
    canvas.height = height
    const ctx = canvas.getContext('2d')
    // 绘制文本
    ctx.translate(width / 2, height / 2)
    ctx.font = joinFontStr(textStyle)
    ctx.textAlign = 'center'
    ctx.textBaseline = 'middle'
    ctx.fillText(text, 0, 0)
    // 获取画布的像素数据
    const image = ctx.getImageData(0, 0, width, height).data
    // 遍历每个像素点,找出有内容的像素点
    const imageData = []
    for (let x = 0; x < width; x++) {for (let y = 0; y < height; y++) {
            // 如果 a 通道不为 0,那么代表该像素点存在内容
            const a = image[x * 4 + y * (width * 4) + 3]
            if (a > 0) {imageData.push([x, y])
            }
        }
    }
    return {
        data: imageData,
        width,
        height
    }
}

首先为了避免出现小数,咱们将计算出的文本大小向上取整作为画布的大小。

而后将画布的中心点从左上角移到核心进行文本的绘制。

接下来通过 getImageData 办法获取到画布的像素数据,获取到的是一个数值数组,顺次保留着画布从左到右,从上到下的每一个像素点的信息,每四位代表一个像素点,别离为:rgba四个通道的值。

为了缩小后续比对的工作量,咱们过滤出存在内容的像素点,也就是存在文本的像素点,空白的像素点能够间接舍弃。因为咱们没有指定文本的色彩,所以默认为彩色,也就是 rgb(0,0,0),那么只能通过a 通道来判断。

另外,除了返回存在内容的像素点数据外,也返回了文本的宽高信息,后续可能会用到。

文本类

接下来咱们来创立一个文本类,用于保留每个文本的一些公有状态:

// 文本类
class WordItem {constructor({ text, weight, fontStyle, color}) {
        // 文本
        this.text = text
        // 权重
        this.weight = weight
        // 字体款式
        this.fontStyle = fontStyle
        // 文本色彩
        this.color = color || getColor()// getColor 办法是一个返回随机色彩的办法
        // 文本像素数据
        this.imageData = getTextImageData(text, fontStyle)
        // 文本渲染的地位
        this.left = 0
        this.top = 0
    }
}

很简略,保留相干的状态,并且计算并保留文本的像素数据。

词云类

接下来创立一下咱们的入口类:

// 词云类
class WordCloud {constructor({ el, minFontSize, maxFontSize, fontFamily, fontWeight}) {
        // 词云渲染的容器元素
        this.el = el
        const elRect = el.getBoundingClientRect()
        this.elWidth = elRect.width
        this.elHeight = elRect.height
        // 字号大小
        this.minFontSize = minFontSize || 12
        this.maxFontSize = maxFontSize || 40
        // 字体
        this.fontFamily = fontFamily || '微软雅黑'
        // 加粗
        this.fontWeight = fontWeight || ''
    }
}

后续的计算中会用到容器的大小,所以须要保留一下。此外也凋谢了字体款式的配置。

接下来增加一个计算的办法:

class WordCloud {
    // 计算词云地位
    run(words = [], done = () => {}) {
        // 按权重从大到小排序
        const wordList = [...words].sort((a, b) => {return b[1] - a[1]
        })
        const minWeight = wordList[wordList.length - 1][1]
        const maxWeight = wordList[0][1]
        // 创立词云文本实例
        const wordItemList = wordList
            .map(item => {const text = item[0]
                const weight = item[1]
                return new WordItem({
                    text,
                    weight,
                    fontStyle: {
                        fontSize: getFontSize(
                            weight,
                            minWeight,
                            maxWeight,
                            this.minFontSize,
                            this.maxFontSize
                        ),
                        fontFamily: this.fontFamily,
                        fontWeight: this.fontWeight
                    }
                })
            })
        }
}

run办法接管两个参数,第一个为文本列表,第二个为执行实现时的回调函数,会把最终的计算结果传递回去。

首先咱们把文本列表按权重从大到小进行了排序,因为词云的渲染中个别权重大的文本会渲染在两头地位,所以咱们从大到小进行计算。

而后给每个文本创立了一个文本实例。

咱们能够这么应用这个类:

const wordCloud = new WordCloud({el: el.value})
wordCloud.run(words, () => {})

计算文本的渲染地位

接下来到了外围局部,即如何计算出每个文本的渲染地位。

具体逻辑如下:

1. 咱们会保护一个 mapkey 为像素点的坐标,valuetrue,代表这个像素点曾经有内容了。

2. 以第一个文本,也就是权重最大的文本作为基准,你能够设想成它就是画布,其余文本都绝对它进行定位,首先将它的所有像素点保留到 map 中,同时记录下它的中心点地位;

3. 顺次遍历后续的每个文本实例,对每个文本实例,从中心点顺次向周围扩散,遍历每个像素点,依据每个文本的像素数据和 map 中的数据判断以后像素点的地位是否包容该文本,能够的话这个像素点即作为该文本最终渲染的地位,也就是设想成渲染到第一个文本造成的画布上,而后将以后文本的像素数据也增加到 map 中,不过要留神,这时每个像素坐标都须要加上计算出来的地位,因为咱们是以第一个文本作为基准。以此类推,计算出所有文本的地位。

增加一个 compute 办法:

class WordCloud {run(words = [], done = () => {}) {
        // ...
        // 计算文本渲染的地位
        this.compute(wordItemList)
        // 返回计算结果
        const res = wordItemList.map(item => {
            return {
                text: item.text,
                left: item.left,
                top: item.top,
                color: item.color,
                fontStyle: item.fontStyle
            }
        })
        done(res)
    }

    // 计算文本的地位
    compute(wordItemList) {for (let i = 0; i < wordItemList.length; i++) {const curWordItem = wordItemList[i]
            // 将第一个文本的像素数据保留到 map 中
            if (i === 0) {addToMap(curWordItem)
                continue
            }
            // 顺次计算后续的每个文本的显示地位
            const res = getPosition(curWordItem)
            curWordItem.left = res[0]
            curWordItem.top = res[1]
            // 计算出地位后的每个文本也须要将像素数据保留到 map 中
            addToMap(curWordItem)
        }
    }
}

调用 compute 办法计算出每个文本的渲染地位,计算完后咱们会调用 done 办法把文本数据传递进来。

compute办法就是后面形容的 23 两步的逻辑,接下来咱们的工作就是实现其中的 addToMapgetPosition 两个办法。

addToMap办法用于保留每个文本的像素数据,同时要记录一下第一个文本的中心点地位:

let pxMap = {}
let centerX = -1
let centerY = -1

// 保留每个文本的像素数据
const addToMap = curWordItem => {
    curWordItem.imageData.data.forEach(item => {const x = item[0] + curWordItem.left
        const y = item[1] + curWordItem.top
        pxMap[x + '|' + y] = true
    })
    // 记录第一个文本的中心点地位
    if (centerX === -1 && centerY === -1) {centerX = Math.floor(curWordItem.imageData.width / 2)
        centerY = Math.floor(curWordItem.imageData.height / 2)
    }
}

很简略,遍历文本的像素数据,以坐标为 key 增加到 map 对象中。

能够看到每个像素点的坐标会加上以后文本的渲染坐标,初始都为 0,所以第一个文本保留的就是它原始的坐标值,后续每个文本都是渲染在第一个文本造成的画布上,所以每个像素点要加上它的渲染坐标,能力转换成第一个文本造成的画布的坐标系上的点。

接下来是 getPosition 办法,首先来看一下示意图:

遍历的终点是第一个文本的中心点,而后一直向周围扩散:

每次扩散造成的矩形的四条边上的所有像素点都须要遍历判断是否符合要求,即这个地位是否包容以后文本,所以咱们须要四个循环。

这样一直扩散,直到找到符合要求的坐标。

因为要向周围扩散,所以须要四个变量来保留:

const getPosition = (curWordItem) => {
    let startX, endX, startY, endY
    // 第一个文本的中心点
    startX = endX = centerX
    startY = endY = centerY
}

以第一个文本的中心点为起始点,也就是开始遍历的地位。初始 startXendX雷同,startYendY 雷同,而后 startXstartY递加,endXendY 递增,达到扩散的成果。

针对每个像素点,咱们怎么判断它是否符合要求呢,很简略,遍历以后文本的每个像素点,加上以后判断的像素点的坐标,转换成第一个文本造成的坐标系上的点,而后去 map 外面找,如果某个像素点曾经在 map 中存在了,代表这个像素点曾经有文本了,那么以后被查看的这个像素所在的地位无奈就齐全包容以后文本,那么进入下一个像素点进行判断,直到找到符合要求的点。

// 判断某个像素点所在位置是否齐全包容某个文本
const canFit = (curWordItem, [cx, cy]) => {if (pxMap[`${cx}|${cy}`]) return false
    return curWordItem.imageData.data.every(([x, y]) => {
        const left = x + cx
        const top = y + cy
        return !pxMap[`${left}|${top}`]
    })
}

首先判断这个像素地位自身是否曾经存在文字了,如果没有,那么遍历文本的所有像素点,须要留神文本的每个像素坐标都要加上以后判断的像素坐标,这样才是以第一个文本为基准的坐标值。

有了这个办法,接下来就能够遍历所有像素点节点判断了:

const getPosition = (curWordItem) => {
    let startX, endX, startY, endY
    startX = endX = centerX
    startY = endY = centerY
    // 判断起始点是否符合要求
    if (canFit(curWordItem, [startX, startY])) {return [startX, startY]
    }
    // 顺次扩散遍历每个像素点
    while (true) {
        // 向下取整作为以后比拟的值
        const curStartX = Math.floor(startX)
        const curStartY = Math.floor(startY)
        const curEndX = Math.floor(endX)
        const curEndY = Math.floor(endY)

        // 遍历矩形右侧的边
        for (let top = curStartY; top < curEndY; ++top) {const value = [curEndX, top]
            if (canFit(curWordItem, value)) {return value}
        }
        // 遍历矩形上面的边
        for (let left = curEndX; left > curStartX; --left) {const value = [left, curEndY]
            if (canFit(curWordItem, value)) {return value}
        }
        // 遍历矩形左侧的边
        for (let top = curEndY; top > curStartY; --top) {const value = [curStartX, top]
            if (canFit(curWordItem, value)) {return value}
        }
        // 遍历矩形下面的边
        for (let left = curStartX; left < curEndX; ++left) {const value = [left, curStartY]
            if (canFit(curWordItem, value)) {return value}
        }

        // 向周围扩散
        startX -= 1
        endX += 1
        startY -= 1
        endY += 1
    }
}

因为咱们是通过像素的坐标来判断,所以不容许呈现小数,都须要进行取整。

对矩形边的遍历咱们是按下图的方向:

当然,你也能够调整成你喜爱的程序。

到这里,应该就能够计算出所有文本的渲染地位了,咱们将文本渲染进去看看成果:

import {ref} from 'vue'

const el = ref(null)
const list = ref([])

const wordCloud = new WordCloud({el: el.value})
wordCloud.run(exampleData, res => {list.value = res})
<div class="container" ref="el">
    <div
         class="wordItem"
         v-for="(item, index) in list"
         :key="index"
         :style="{
                 left: item.left + 'px',
                 top: item.top + 'px',
                 fontSize: item.fontStyle.fontSize + 'px',
                 fontFamily: item.fontStyle.fontFamily,
                 color: item.color
         }"
         >
        {{item.text}}
    </div>
</div>
.container {
    width: 600px;
    height: 400px;
    border: 1px solid #000;
    margin: 200px auto;
    position: relative;

    .wordItem {
        position: absolute;
        white-space: nowrap;
    }
}

以上是 Vue3 的代码示例,容器元素设为绝对定位,文本元素设为相对定位,而后将计算出来的地位作为 lefttop值,不要忘了设置字号、字体等款式。成果如下:

为了不便的看出每个文本的权重,把权重值也显示进去了。

首先能够看到有极少数文字还是产生了重叠,这个其实很难防止,因为咱们始终在各种取整。

另外能够看到文字的散布是和咱们后面遍历的程序是统一的。

适配容器

当初咱们看一下文本数量比拟多的状况:

能够看到咱们给的容器是宽比高长的,而渲染进去云图靠近一个正方形,这样放到容器里显然没方法齐全铺满,所以最好咱们计算出来的云图的比例和容器的比例是统一的。

解决这个问题能够从扩散的步长下手,目前咱们向周围扩散的步长都是 1,如果宽比高长,那么垂直方向曾经扩散出以后像素区域了,而程度方向还在外部,那么显然最初垂直方向上排列的就比拟多了,咱们要依据容器的长宽比来调整这个步长,让垂直和程度方向扩散到边界的工夫是一样的:

class WordCloud {compute(wordItemList) {for (let i = 0; i < wordItemList.length; i++) {const curWordItem = wordItemList[i]
      // ...
      // 计算文本渲染地位时传入容器的宽高
      const res = getPosition({
        curWordItem,
        elWidth: this.elWidth,
        elHeight: this.elHeight
      })
      // ...
    }
  }
}
const getPosition = ({elWidth, elHeight, curWordItem}) => {
    // ...
    // 依据容器的宽高来计算扩散步长
    let stepLeft = 1,
        stepTop = 1
    if (elWidth > elHeight) {
        stepLeft = 1
        stepTop = elHeight / elWidth
    } else if (elHeight > elWidth) {
        stepTop = 1
        stepLeft = elWidth / elHeight
    }
    // ...
    while (true) {
        // ...
        startX -= stepLeft
        endX += stepLeft
        startY -= stepTop
        endY += stepTop
    }
}

计算文本渲染地位时传入容器的宽高,如果宽比高长,那么垂直方向步长就得更小一点,反之亦然。

此时咱们再来看看成果:

是不是基本上统一了。

当初咱们来看下一个问题,那就是大小适配,咱们将最小的文字大小调大一点看看:

能够发现词云曾经比容器大了,这显然不行,所以最初咱们还要来依据容器大小来调整词云的大小,怎么调整呢,依据容器大小缩放词云整体的地位和字号。

首先咱们要晓得词云整体的大小,这能够最初遍历 map 来计算,当然也能够在 addToMap 函数内同时计算:

let left = Infinity
let right = -Infinity
let top = Infinity
let bottom = -Infinity

const addToMap = curWordItem => {
    curWordItem.imageData.data.forEach(item => {const x = item[0] + curWordItem.left
        const y = item[1] + curWordItem.top
        pxMap[x + '|' + y] = true
        // 更新边界
        left = Math.min(left, x)
        right = Math.max(right, x)
        top = Math.min(top, y)
        bottom = Math.max(bottom, y)
    })
    // ...
}

// 获取边界数据
const getBoundingRect = () => {
    return {
        left,
        right,
        top,
        bottom,
        width: right - left,
        height: bottom - top
    }
}

减少了四个变量来保留所有文本渲染后的边界数据,同时增加了一个函数来获取这个信息。

接下来给 WordCloud 类减少一个办法,用来适配容器的大小:

class WordCloud {run(words = [], done = () => {}) {
        // ...
        this.compute(wordItemList)
        this.fitContainer(wordItemList)// ++
        const res = wordItemList.map(item => {return {}
        })
        done(res)
    }

    // 依据容器大小调整字号
    fitContainer(wordItemList) {
        const elRatio = this.elWidth / this.elHeight
        const {width, height} = getBoundingRect()
        const wordCloudRatio = width / height
        let w, h
        if (elRatio > wordCloudRatio) {
            // 词云高度以容器高度为准,宽度依据原比例进行缩放
            h = this.elHeight
            w = wordCloudRatio * this.elHeight
        } else {
            // 词云宽度以容器宽度为准,高度依据原比例进行缩放
            w = this.elWidth
            h = this.elWidth / wordCloudRatio
        }
        const scale = w / width
        wordItemList.forEach(item => {
            item.left *= scale
            item.top *= scale
            item.fontStyle.fontSize *= scale
        })
    }
}

依据词云的宽高比和容器的宽高比进行缩放,计算出缩放倍数,而后利用到词云所有文本的渲染坐标、字号上。当初再来看看成果:

当初还有最初一个问题要解决,就是渲染地位的调整,因为目前所有文本渲染的地位都是绝对于第一个文本的,因为第一个文本的地位为0,0,所以它处于容器的左上角,咱们要调整为整体在容器中居中。

如图所示,第一个文本的地位为 0,0,所以右边和上边超出的间隔就是边界数据中的lefttop 值,那么把词云移入容器,只有整体挪动 -left-top 间隔即可。

接下来是挪动到核心,这个只有依据后面的比例来判断挪动程度还是垂直的地位即可:

所以这个逻辑也能够写在 fitContainer 办法中:

class WordCloud {fitContainer(wordItemList) {
        const elRatio = this.elWidth / this.elHeight
        let {width, height, left, top} = getBoundingRect()
        const wordCloudRatio = width / height
        let w, h
        // 整体平移间隔
        let offsetX = 0,
            offsetY = 0
        if (elRatio > wordCloudRatio) {} else {}
        const scale = w / width
        // 将词云挪动到容器两头
        left *= scale
        top *= scale
        if (elRatio > wordCloudRatio) {
            offsetY = -top
            offsetX = -left + (this.elWidth - w) / 2
        } else {
            offsetX = -left
            offsetY = -top + (this.elHeight - h) / 2
        }
        wordItemList.forEach(item => {
            item.left *= scale
            item.top *= scale
            item.left += offsetX
            item.top += offsetY
            item.fontStyle.fontSize *= scale
        })
    }
}

到这里,一个根本的词云成果就实现了。

加快速度

以上代码能够工作,然而它的速度十分慢,因为要遍历的像素点数据比拟宏大,所以耗时是以分钟计的:

这显然是无奈承受的,浏览器都无法忍受弹出了退出页面的提醒,那么怎么缩小一点工夫呢,后面说了首先是因为要遍历的像素点太多了,那么是不是能够缩小像素点呢,当然是能够的,咱们最初有一步适配容器大小的操作,既然都是要最初来整体缩放的,那不如一开始就给所有的文本的字号放大肯定倍数,字号小了,那么像素点显然也会变少,进而计算的速度就会放慢:

class WordCloud {constructor({ el, minFontSize, maxFontSize, fontFamily, fontWeight, fontSizeScale}) {
        // ...
        // 文字整体的放大比例,用于放慢计算速度
        this.fontSizeScale = fontSizeScale || 0.1
    }

    run(words = [], done = () => {}) {
        // ...
        const wordItemList = wordList.map(item => {const text = item[0]
            const weight = item[1]
            return new WordItem({
                text,
                weight,
                fontStyle: {fontSize: getFontSize() * this.fontSizeScale,// ++
                    fontFamily: this.fontFamily,
                    fontWeight: this.fontWeight
                }
            })
        })
     }
}

这个比例你能够本人调整,越小速度越快,当然,也不能太小,太小文字都渲染不了了。当初来看一下耗时:

能够看到,耗时由分钟级减至毫秒级,成果还是十分不错的。

当然,这毕竟还是一个计算密集型工作,所以能够通过 Web worker 放在独立线程中去执行。

间距

目前文本之间根本是紧挨着,接下来增加点间距。

因为咱们是通过检测某个像素点上有没有文字,所有只有在检测阶段让间距的地位上存在内容,最初理论显示文字时是空白,那么就实现了间距的增加。

后面获取文字的像素数据时咱们是通过 ctx.fillText 来绘制文字,还有一个 strokeText 办法能够用于绘制文字的轮廓,它能够受 lineWidth 属性影响,当 lineWidth 设置的越大,文字线条也越粗,咱们就能够通过这个个性来实现间距,只在获取文字的像素数据时设置 lineWidth,比方设置为 10,最终通过DOM 渲染文字的时候没有这个设置,线宽为 1,那么就多了 9 的间距。

这个 lineWidth 怎么设置呢,能够间接写死某个数值,也能够绝对于文字的字号:

const getTextImageData = (text, fontStyle, space = 0) => {
    // 绝对于字号的间距
    const lineWidth = space * fontStyle.fontSize * 2
    let {width, height} = measureText(text, fontStyle, lineWidth)
    // 线条变粗了,文字宽高也会变大
    width = Math.ceil(width + lineWidth)
    height = Math.ceil(height + lineWidth)
    // ...
    ctx.fillText(text, 0, 0)
    // 如果要设置间距,则应用 strokeText 办法绘制文本
    if (lineWidth > 0) {
        ctx.lineWidth = lineWidth
        ctx.strokeText(text, 0, 0)
    }
}

线条两侧的间距各为字号的倍数,则总的线宽须要乘 2。

线条加粗了,文字的宽高也会变大,减少的大小就是间距的大小。

最初应用 strokeText 办法绘制文本即可。

接下来给文本类增加上间距的属性:

// 文本类
class WordItem {constructor({ text, weight, fontStyle, color, space}) {
    // 间距
    this.space = space || 0
    // 文本像素数据
    this.imageData = getTextImageData(text, fontStyle, this.space)
    // ...
  }
}

WordCloud同样也加上这个属性,这里就略过了。

space 设置为 0.5 时的成果如下:

旋转

接下来咱们让文字反对旋转。

首先要批改的是获取文字像素数据的办法,因为 canvas 的大小目前是依据文字的宽高设置的,当文字旋转后显然就不行了:

如图所示,绿色的是文字未旋转时的突围框,当文字旋转后,咱们须要的是红色的突围框,那么问题就转换成了如何依据文字的宽高和旋转角度计算出旋转后的文字的突围框。

这个计算也很简略,只须要用到最简略的三角函数即可。

宽度的计算能够参考上图,因为文字是一个矩形,不是一条线,所以须要两段长度相加:

width * Math.cos(r) + height * Math.sin(r)

高度的计算也是一样的:

width * Math.sin(rad) + height * Math.cos(rad)

由此咱们能够失去如下的函数:

// 计算旋转后的矩形的宽高
const getRotateBoundingRect = (width, height, rotate) => {const rad = degToRad(rotate)
    const w = width * Math.abs(Math.cos(rad)) + height * Math.abs(Math.sin(rad))
      const h = width * Math.abs(Math.sin(rad)) + height * Math.abs(Math.cos(rad))
    return {width: Math.ceil(w),
        height: Math.ceil(h)
    }
}

// 角度转弧度
const degToRad = deg => {return (deg * Math.PI) / 180
}

因为三角函数计算出来可能是正数,然而宽高总不能是负的,所以须要转成负数。

那么咱们就能够在 getTextImageData 办法中应用这个函数了:

// 获取文字的像素点数据
const getTextImageData = (text, fontStyle, rotate = 0) => {
    // ...
    const rect = getRotateBoundingRect(
        width + lineWidth,
        height + lineWidth,
        rotate
      )
    width = rect.width
    height = rect.height
    canvas.width = width
      canvas.height = height
    // ...
    // 绘制文本
    ctx.translate(width / 2, height / 2)
    ctx.rotate(degToRad(rotate))
    // ...
}

不要忘了通过 rotate 办法旋转文字。

因为咱们的检测是基于像素的,所以文字具体怎么旋转其实都无所谓,那么像素检测过程无需批改。

当初来给文本类增加一个角度属性:

// 文本类
class WordItem {constructor({ text, weight, fontStyle, color, rotate}) {
        // ...
        // 旋转角度
        this.rotate = rotate
        // ...
        // 文本像素数据
        this.imageData = getTextImageData(text, fontStyle, this.space, this.rotate)
        // ...
    }
}

而后在返回计算结果的中央也加上角度:

class WordCloud {run(words = [], done = () => {}) {
        // ...
        const res = wordItemList.map(item => {
            return {
                // ...
                rotate: item.rotate
            }
        })
        done(res)
    }
}

最初,渲染时加上旋转的款式就能够了:

<div
     class="wordItem"
     v-for="(item, index) in list"
     :key="index"
     :style="{
             // ...
             transform: `rotate(${item.rotate}deg)`
      }"
     >
    {{item.text}}
</div>

来看看成果:

能够看到很多文字都重叠了,这是为什么呢,首先自信一点,地位计算必定是没有问题的,那么问题只能出在最初的显示上,认真思考就会发现,咱们计算出来的地位是文本突围框的左上角,然而最初用 css 设置文本旋转时地位就不对了,咱们能够在每个文本计算出来的地位上渲染一个小圆点,就能够比拟直观的看出差距:

比方对于文本 网易 46,它的理论渲染的地位应该如下图所示才对:

解决这个问题能够通过批改 DOM 构造及款式。咱们给 wordItem 元素里面再套一个元素,作为文本突围框,宽高设置为文本突围框的宽高,而后让 wordItem 元素在该元素中程度和垂直居中即可。

首先给文本类增加两个属性:

// 文本类
class WordItem {constructor({ text, weight, fontStyle, color, space, rotate}) {
        // 文本像素数据
        this.imageData = getTextImageData(text, fontStyle, this.space, this.rotate)
        // 文本突围框的宽高
        this.width = this.imageData.width
        this.height = this.imageData.height
    }
}

而后不要忘了在适配容器大小办法中也须要调整这个宽高:

class WordCloud {fitContainer(wordItemList) {
        // ...
        wordItemList.forEach(item => {
            // ...
            item.width *= scale
            item.height *= scale
            item.fontStyle.fontSize *= scale
        })
    }
}

DOM 结构调整为如下:

<div class="container" ref="el">
    <div
         class="wordItemWrap"
         v-for="(item, index) in list"
         :key="index"
         :style="{
                 left: item.left + 'px',
                 top: item.top + 'px',
                 width: item.width + 'px',
                 height: item.height + 'px'
         }"
     >
        <div
             class="wordItem"
             :style="{
                     fontSize: item.fontStyle.fontSize + 'px',
                     fontFamily: item.fontStyle.fontFamily,
                     fontWeight: item.fontStyle.fontWeight,
                     color: item.color,
                     transform: `rotate(${item.rotate}deg)`
              }"
             >
            {{item.text}}
        </div>
    </div>
</div>
.wordItemWrap {
    position: absolute;
    display: flex;
    justify-content: center;
    align-items: center;

    .wordItem {white-space: nowrap;}
}

当初来看看成果:

解决文本超出容器的问题

有时右侧和下方的文本会超出容器大小,为了不便查看增加一个背景色:

这是为什么呢,起因可能有两个,一是因为咱们获取文本像素时是放大了文字字号的,导致最初放大后存在偏差;二是最初咱们对文本的宽高也进行了缩放,然而文本宽高和文字字号并不齐全成正比,导致宽高和理论文字大小不统一。

解决第二个问题能够通过从新计算文本宽高,咱们将获取文本突围框的逻辑由 getTextImageData 办法中提取成一个办法:

// 获取文本的外突围框大小
const getTextBoundingRect = ({
  text,
  fontStyle,
  space,
  rotate
} = {}) => {
  const lineWidth = space * fontStyle.fontSize * 2
  // 获取文本的宽高,并向上取整
  const {width, height} = measureText(text, fontStyle)
  const rect = getRotateBoundingRect(
    width + lineWidth,
    height + lineWidth,
    rotate
  )
  return {
    ...rect,
    lineWidth
  }
}

而后在 fitContainer 办法中在缩放了文本字号后从新计算文本突围框:

class WordCloud {fitContainer(wordItemList) {
        wordItemList.forEach(item => {
            // ...
            item.fontStyle.fontSize *= scale
            // 从新计算文本突围框大小而不是间接缩放,因为文本突围框大小和字号并不成正比
            const {width, height} = getTextBoundingRect({
                text: item.text,
                fontStyle: item.fontStyle,
                space: item.space,
                rotate: item.rotate
            })
            item.width = width
            item.height = height
        })
    }
}

这样下方的文本超出问题就解决了,然而右侧还是会存在问题:

解决形式也很简略,间接依据文本元素的地位和大小判断是否超出了容器,是的话就调整一下地位:

class WordCloud {fitContainer(wordItemList) {
        wordItemList.forEach(item => {
            // ...
            item.fontStyle.fontSize *= scale
            // 从新计算文本突围框大小而不是间接缩放,因为文本突围框大小和字号并不成正比
            // ...
            // 修改超出容器文本
            if (item.left + item.width > this.elWidth) {item.left = this.elWidth - item.width}
            if (item.top + item.height > this.elHeight) {item.top = this.elHeight - item.height}
        })
    }
}

到这里,一个简略的词云成果就实现了:

总结

本文具体介绍了如何从零开始实现一个简略的词云成果,实现上局部参考了 VueWordCloud 这个我的项目。

笔者也封装成了一个简略的库,能够间接调用,感兴趣的能够移步仓库:https://github.com/wanglin2/simple-word-cloud。

正文完
 0