如何從零實現一個詞雲效果

街角小林發表於2024-02-26

詞雲是一種文字資料的視覺化形式,它富有表現力,透過大小不一,五顏六色,隨機緊挨在一起的文字形式,可以在眾多文字中直觀地突出出現頻率較高的關鍵詞,給予視覺上的突出,從而過濾掉大量的文字資訊,在實際專案中,我們可以選擇使用wordcloud2VueWordCloud等開源庫來實現,但是你有沒有好奇過它是怎麼實現的呢,本文會嘗試從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四個通道的值。

image-20240204143506046.png

為了減少後續比對的工作量,我們過濾出存在內容的畫素點,也就是存在文字的畫素點,空白的畫素點可以直接捨棄。因為我們沒有指定文字的顏色,所以預設為黑色,也就是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, () => {})

image-20240204172948815.png

計算文字的渲染位置

接下來到了核心部分,即如何計算出每個文字的渲染位置。

具體邏輯如下:

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方法,首先來看一下示意圖:

image-20240204182541132.png

遍歷的起點是第一個文字的中心點,然後不斷向四周擴散:

image-20240204182717063.png

每次擴散形成的矩形的四條邊上的所有畫素點都需要遍歷判斷是否符合要求,即這個位置能否容納當前文字,所以我們需要四個迴圈。

這樣不斷擴散,直到找到符合要求的座標。

因為要向四周擴散,所以需要四個變數來儲存:

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}`]
    })
}

首先判斷這個畫素位置本身是否已經存在文字了,如果沒有,那麼遍歷文字的所有畫素點,需要注意文字的每個畫素座標都要加上當前判斷的畫素座標,這樣才是以第一個文字為基準的座標值。

有了這個方法,接下來就可以遍歷所有畫素點節點判斷了:

image-20240204190015172.png

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
    }
}

因為我們是透過畫素的座標來判斷,所以不允許出現小數,都需要進行取整。

對矩形邊的遍歷我們是按下圖的方向:

image-20240205151752045.png

當然,你也可以調整成你喜歡的順序。

到這裡,應該就可以計算出所有文字的渲染位置了,我們將文字渲染出來看看效果:

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值,不要忘了設定字號、字型等樣式。效果如下:

image-20240205151853732.png

為了方便的看出每個文字的權重,把權重值也顯示出來了。

首先可以看到有極少數文字還是發生了重疊,這個其實很難避免,因為我們一直在各種取整。

另外可以看到文字的分佈是和我們前面遍歷的順序是一致的。

適配容器

現在我們看一下文字數量比較多的情況:

image-20240205152653088.png

可以看到我們給的容器是寬比高長的,而渲染出來雲圖接近一個正方形,這樣放到容器裡顯然沒辦法完全鋪滿,所以最好我們計算出來的雲圖的比例和容器的比例是一致的。

解決這個問題可以從擴散的步長下手,目前我們向四周擴散的步長都是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
    }
}

計算文字渲染位置時傳入容器的寬高,如果寬比高長,那麼垂直方向步長就得更小一點,反之亦然。

此時我們再來看看效果:

image-20240205154027691.png

是不是基本上一致了。

現在我們來看下一個問題,那就是大小適配,我們將最小的文字大小調大一點看看:

image-20240205160515471.png

可以發現詞雲已經比容器大了,這顯然不行,所以最後我們還要來根據容器大小來調整詞雲的大小,怎麼調整呢,根據容器大小縮放詞雲整體的位置和字號。

首先我們要知道詞雲整體的大小,這可以最後遍歷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
        })
    }
}

根據詞雲的寬高比和容器的寬高比進行縮放,計算出縮放倍數,然後應用到詞雲所有文字的渲染座標、字號上。現在再來看看效果:

image-20240205164340620.png

現在還有最後一個問題要解決,就是渲染位置的調整,因為目前所有文字渲染的位置都是相對於第一個文字的,因為第一個文字的位置為0,0,所以它處於容器的左上角,我們要調整為整體在容器中居中。

image-20240205170517291.png

如圖所示,第一個文字的位置為0,0,所以左邊和上邊超出的距離就是邊界資料中的lefttop值,那麼把詞雲移入容器,只要整體移動-left-top距離即可。

接下來是移動到中心,這個只要根據前面的比例來判斷移動水平還是垂直的位置即可:

image-20240205170923369.png

所以這個邏輯也可以寫在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
        })
    }
}

image-20240205171240137.png

到這裡,一個基本的詞雲效果就完成了。

加快速度

以上程式碼可以工作,但是它的速度非常慢,因為要遍歷的畫素點資料比較龐大,所以耗時是以分鐘計的:

image-20240219092652404.png

這顯然是無法接受的,瀏覽器都無法忍受彈出了退出頁面的提示,那麼怎麼減少一點時間呢,前面說了首先是因為要遍歷的畫素點太多了,那麼是不是可以減少畫素點呢,當然是可以的,我們最後有一步適配容器大小的操作,既然都是要最後來整體縮放的,那不如一開始就給所有的文字的字號縮小一定倍數,字號小了,那麼畫素點顯然也會變少,進而計算的速度就會加快:

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
                }
            })
        })
     }
}

這個比例你可以自己調整,越小速度越快,當然,也不能太小,太小文字都渲染不了了。現在來看一下耗時:

image-20240219141759813.png

可以看到,耗時由分鐘級減至毫秒級,效果還是非常不錯的。

當然,這畢竟還是一個計算密集型任務,所以可以透過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時的效果如下:

image-20240219151658451.png

旋轉

接下來我們讓文字支援旋轉。

首先要修改的是獲取文字畫素資料的方法,因為canvas的大小目前是根據文字的寬高設定的,當文字旋轉後顯然就不行了:

image-20240219095637550.png

如圖所示,綠色的是文字未旋轉時的包圍框,當文字旋轉後,我們需要的是紅色的包圍框,那麼問題就轉換成了如何根據文字的寬高和旋轉角度計算出旋轉後的文字的包圍框。

這個計算也很簡單,只需要用到最簡單的三角函式即可。

image-20240219102834306.png

寬度的計算可以參考上圖,因為文字是一個矩形,不是一條線,所以需要兩段長度相加:

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

高度的計算也是一樣的:

image-20240219103053320.png

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>

來看看效果:

image-20240219153642854.png

可以看到很多文字都重疊了,這是為什麼呢,首先自信一點,位置計算肯定是沒有問題的,那麼問題只能出在最後的顯示上,仔細思考就會發現,我們計算出來的位置是文字包圍框的左上角,但是最後用css設定文字旋轉時位置就不對了,我們可以在每個文字計算出來的位置上渲染一個小圓點,就可以比較直觀的看出差距:

image-20240219154856980.png

比如對於文字網易46,它的實際渲染的位置應該如下圖所示才對:

image-20240219155018087.png

解決這個問題可以透過修改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;
    }
}

現在來看看效果:

image-20240219163001205.png

解決文字超出容器的問題

有時右側和下方的文字會超出容器大小,為了方便檢視新增一個背景色:

image-20240220102353638.png

這是為什麼呢,原因可能有兩個,一是因為我們獲取文字畫素時是縮小了文字字號的,導致最後放大後存在偏差;二是最後我們對文字的寬高也進行了縮放,但是文字寬高和文字字號並不完全成正比,導致寬高和實際文字大小不一致。

解決第二個問題可以透過重新計算文字寬高,我們將獲取文字包圍框的邏輯由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
        })
    }
}

這樣下方的文字超出問題就解決了,但是右側還是會存在問題:

image-20240220103025217.png

解決方式也很簡單,直接根據文字元素的位置和大小判斷是否超出了容器,是的話就調整一下位置:

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
            }
        })
    }
}

到這裡,一個簡單的詞雲效果就完成了:

image-20240220103155851.png

總結

本文詳細介紹瞭如何從零開始實現一個簡單的詞雲效果,實現上部分參考了VueWordCloud這個專案。

筆者也封裝成了一個簡單的庫,可以直接呼叫,感興趣的可以移步倉庫:https://github.com/wanglin2/simple-word-cloud

相關文章