Files
2026-09-24 16:25:22 +08:00

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>