281 lines
8.9 KiB
Plaintext
281 lines
8.9 KiB
Plaintext
<script lang="ts" setup>
|
|
import { checkIsCssUnit, getUid } from '../../core/util/xCoreUtil.uts'
|
|
import { getDefaultColor } from "../../core/util/xCoreColorUtil.uts"
|
|
import { xConfig } from "../../config/xConfig.uts"
|
|
import { TEXTCLOUD_ITEM_INFO } from "../../interface.uts"
|
|
import { computed, ref, onMounted, watch, nextTick, getCurrentInstance, type Ref } from 'vue'
|
|
|
|
type WordPosition = {
|
|
x : number
|
|
y : number
|
|
width : number
|
|
height : number
|
|
text : string
|
|
fontSize : number
|
|
color : string
|
|
}
|
|
type PROPS_TYPE = {
|
|
width : string
|
|
height : string
|
|
color : string
|
|
bgColor : string
|
|
}
|
|
|
|
|
|
type Props = {
|
|
width : string,
|
|
height : string,
|
|
backgroundColor : string,
|
|
list : TEXTCLOUD_ITEM_INFO[],
|
|
color : string
|
|
}
|
|
|
|
/**
|
|
* @name 词云 TextCloud
|
|
* @description 词从中心向外椭圆螺旋紧凑排布,大小按权重自适应,不重叠。
|
|
* @page /pages/index/text-cloud
|
|
* @category 展示组件
|
|
* @constant 平台兼容
|
|
* | Harmony | H5 | andriod | IOS | 小程序 | UTS | UNIAPP-X SDK | version |
|
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
| ☑ | ☑ | ☑️ | ☑️ | ☑️ | ☑️ | 4.76+ | 1.1.18 |
|
|
*/
|
|
defineOptions({ name: 'xTextCloud' })
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
width: '100%',
|
|
height: '300rpx',
|
|
backgroundColor: 'transparent',
|
|
list: [] as TEXTCLOUD_ITEM_INFO[],
|
|
color: 'primary'
|
|
})
|
|
|
|
const _props = computed(() : PROPS_TYPE => {
|
|
return {
|
|
width: checkIsCssUnit(props.width, 'rpx'),
|
|
height: checkIsCssUnit(props.height, 'rpx'),
|
|
bgColor: getDefaultColor(props.backgroundColor),
|
|
color: getDefaultColor(props.color),
|
|
} as PROPS_TYPE
|
|
})
|
|
|
|
const _list = computed(() : TEXTCLOUD_ITEM_INFO[] => {
|
|
return props.list.map((el : TEXTCLOUD_ITEM_INFO) : TEXTCLOUD_ITEM_INFO => {
|
|
return {
|
|
text: el.text,
|
|
color: el?.color ?? _props.value.color,
|
|
weight: el.weight
|
|
} as TEXTCLOUD_ITEM_INFO
|
|
})
|
|
})
|
|
|
|
const id = ("xTextCloud" + getUid()) as string
|
|
const proxy = getCurrentInstance()?.proxy ?? null
|
|
const boxWidth = ref(0)
|
|
const boxHeight = ref(0)
|
|
const tid = ref(0)
|
|
const placedWords = ref([] as WordPosition[])
|
|
|
|
function calcFontSize(weight : number, maxWeight : number) : number {
|
|
const maxFontSize = 32
|
|
const minFontSize = 12
|
|
if (maxWeight <= 0) return minFontSize
|
|
const size = (weight / maxWeight) * (maxFontSize - minFontSize) + minFontSize
|
|
return Math.round(size)
|
|
}
|
|
|
|
function calcMeasureText(ctx : CanvasRenderingContext2D, text : string, fontSize : number) : number {
|
|
let w = ctx.measureText(text).width
|
|
// #ifdef APP-HARMONY
|
|
let totalWidth = 0
|
|
for (let i = 0; i < text.length; i++) {
|
|
const char = text.charAt(i)
|
|
const code = char.charCodeAt(0)
|
|
if ((code >= 0x4e00 && code <= 0x9fff) || (code >= 0x3000 && code <= 0x303f) || (code >= 0xff00 && code <= 0xffef)) {
|
|
totalWidth += fontSize
|
|
} else {
|
|
totalWidth += fontSize * 0.5
|
|
}
|
|
}
|
|
w = totalWidth
|
|
// #endif
|
|
return w
|
|
}
|
|
|
|
function isOverlap(a : WordPosition, b : WordPosition, gap : number) : boolean {
|
|
return !((a.x + a.width + gap) < b.x || a.x > (b.x + b.width + gap) || (a.y + a.height + gap) < b.y || a.y > (b.y + b.height + gap))
|
|
}
|
|
|
|
function withinBounds(x : number, y : number, w : number, h : number, cw : number, ch : number) : boolean {
|
|
return x >= 0 && y >= 0 && (x + w) <= cw && (y + h) <= ch
|
|
}
|
|
|
|
function layoutWords(ctx : CanvasRenderingContext2D, cw : number, ch : number) {
|
|
placedWords.value = [] as WordPosition[]
|
|
if (_list.value.length == 0) return
|
|
const maxWeight = Math.max(..._list.value.map(i => i.weight))
|
|
// 大到小,中心优先放大字
|
|
const words = _list.value.slice().sort((a, b) => b.weight - a.weight)
|
|
const centerX = cw / 2
|
|
const centerY = ch / 2
|
|
const ellipseScale = ch / cw // 椭圆纵向缩放
|
|
// 调整步长:更大的角度步长,半径更平滑,尝试次数更少
|
|
const angleStep = 0.45
|
|
const radiusStep = Math.max(5, Math.min(cw, ch) / 80)
|
|
const gap = 2
|
|
ctx.textBaseline = 'top'
|
|
// 空间哈希:将画布分成网格,加速碰撞检测
|
|
const cellSize = Math.max(14, Math.min(cw, ch) / 18)
|
|
const grid = new Map<string, number[]>()
|
|
const keyOf = (cx:number,cy:number):string => cx.toString()+","+cy.toString()
|
|
const addToGrid = (idx:number, rect:WordPosition)=>{
|
|
const mincX = Math.floor(rect.x / cellSize)
|
|
const mincY = Math.floor(rect.y / cellSize)
|
|
const maxcX = Math.floor((rect.x + rect.width) / cellSize)
|
|
const maxcY = Math.floor((rect.y + rect.height) / cellSize)
|
|
for(let gx=mincX; gx<=maxcX; gx++){
|
|
for(let gy=mincY; gy<=maxcY; gy++){
|
|
const k = keyOf(gx,gy)
|
|
const arr = grid.get(k) ?? []
|
|
arr.push(idx)
|
|
grid.set(k,arr)
|
|
}
|
|
}
|
|
}
|
|
const getNearbyIndices = (rect:WordPosition):number[]=>{
|
|
const mincX = Math.floor(rect.x / cellSize)
|
|
const mincY = Math.floor(rect.y / cellSize)
|
|
const maxcX = Math.floor((rect.x + rect.width) / cellSize)
|
|
const maxcY = Math.floor((rect.y + rect.height) / cellSize)
|
|
const result:number[] = []
|
|
const seen = new Set<number>()
|
|
for(let gx=mincX-1; gx<=maxcX+1; gx++){
|
|
for(let gy=mincY-1; gy<=maxcY+1; gy++){
|
|
const k = keyOf(gx,gy)
|
|
const arr = grid.get(k)
|
|
if(arr!=null){
|
|
for(let i=0;i<arr.length;i++){ const id = arr[i]; if(!seen.has(id)){ seen.add(id); result.push(id) } }
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
for (let i = 0; i < words.length; i++) {
|
|
const item = words[i]
|
|
const fontSize = calcFontSize(item.weight, maxWeight)
|
|
ctx.font = `${fontSize}px Arial`
|
|
const w = calcMeasureText(ctx, item.text, fontSize)
|
|
const h = fontSize
|
|
let placed = false
|
|
const maxTry = 900
|
|
for (let k = 0; k < maxTry; k++) {
|
|
const angle = k * angleStep
|
|
const radius = 2 + k * radiusStep / (2 * Math.PI)
|
|
const cx = centerX + Math.cos(angle) * radius
|
|
const cy = centerY + Math.sin(angle) * radius * ellipseScale
|
|
const x = cx - w / 2
|
|
const y = cy - h / 2
|
|
if (!withinBounds(x, y, w, h, cw, ch)) continue
|
|
const candidate = { x, y, width: w, height: h, text: item.text, fontSize, color: item?.color ?? '#333333' } as WordPosition
|
|
let collide = false
|
|
const near = getNearbyIndices(candidate)
|
|
for (let j = 0; j < near.length; j++) {
|
|
const pw = placedWords.value[near[j]]
|
|
if (isOverlap(candidate, pw, gap)) { collide = true; break }
|
|
}
|
|
if (!collide) {
|
|
const idx = placedWords.value.length
|
|
placedWords.value.push(candidate)
|
|
addToGrid(idx, candidate)
|
|
placed = true
|
|
break
|
|
}
|
|
}
|
|
if (!placed) {
|
|
// 兜底:按行紧凑填充
|
|
let cursorX = 0
|
|
let cursorY = 0
|
|
let lineHeight = 0
|
|
while (cursorY + h <= ch) {
|
|
if (cursorX + w > cw) { cursorX = 0; cursorY += lineHeight + gap; lineHeight = 0 }
|
|
const cand = { x: cursorX, y: cursorY, width: w, height: h, text: item.text, fontSize, color: item?.color ?? '#333333' } as WordPosition
|
|
let hit = false
|
|
const near = getNearbyIndices(cand)
|
|
for (let t = 0; t < near.length; t++) { if (isOverlap(cand, placedWords.value[near[t]], gap)) { hit = true; break } }
|
|
if (!hit) { const idx = placedWords.value.length; placedWords.value.push(cand); addToGrid(idx,cand); break }
|
|
cursorX += Math.max(4, w * 0.25)
|
|
if (h > lineHeight) lineHeight = h
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function draw() {
|
|
uni.createCanvasContextAsync({
|
|
id: id as string,
|
|
component: proxy,
|
|
success: (context : CanvasContext) => {
|
|
const ctx2d = context.getContext('2d')!
|
|
const canvas = ctx2d.canvas
|
|
const dpr = uni.getWindowInfo().pixelRatio
|
|
canvas.width = canvas.offsetWidth * dpr
|
|
canvas.height = canvas.offsetHeight * dpr
|
|
ctx2d.scale(dpr, dpr)
|
|
// 背景
|
|
ctx2d.clearRect(0, 0, canvas.width, canvas.height)
|
|
ctx2d.fillStyle = _props.value.bgColor
|
|
ctx2d.fillRect(0, 0, canvas.offsetWidth, canvas.offsetHeight)
|
|
// 布局
|
|
layoutWords(ctx2d, canvas.offsetWidth, canvas.offsetHeight)
|
|
// 绘制
|
|
for (let i = 0; i < placedWords.value.length; i++) {
|
|
const w = placedWords.value[i]
|
|
ctx2d.font = `${w.fontSize}px Arial`
|
|
ctx2d.fillStyle = w.color
|
|
ctx2d.textBaseline = 'top'
|
|
ctx2d.fillText(w.text, w.x, w.y)
|
|
}
|
|
// // #ifdef APP
|
|
// ctx2d.update()
|
|
// // #endif
|
|
}
|
|
})
|
|
}
|
|
|
|
function measureAndDraw() {
|
|
uni.createSelectorQuery().in(proxy)
|
|
.select('.xTextCloud')
|
|
.boundingClientRect()
|
|
.exec((ret:any[]) => {
|
|
if (ret.length == 0) return
|
|
const node = ret[0] as NodeInfo
|
|
boxWidth.value = node.width!
|
|
boxHeight.value = node.height!
|
|
nextTick(() => { draw() })
|
|
})
|
|
}
|
|
|
|
onMounted(() => {
|
|
clearTimeout(tid.value)
|
|
tid.value = setTimeout(() => { measureAndDraw() }, 120)
|
|
})
|
|
watch([
|
|
():any => _list.value,
|
|
():any => _props.value.width,
|
|
():any => _props.value.height,
|
|
():any => _props.value.bgColor,
|
|
():any => _props.value.color
|
|
],
|
|
() => {
|
|
measureAndDraw()
|
|
})
|
|
</script>
|
|
<template>
|
|
|
|
<view class="xTextCloud" :style="{width:_props.width,height:_props.height,backgroundColor:_props.bgColor}">
|
|
<canvas :id="id" :style="{width:'100%',height:'100%'}"></canvas>
|
|
</view>
|
|
|
|
</template>
|
|
<style scoped>
|
|
</style> |