Files
JiaZhiQianYan/components/WordCloud/WordCloud.vue
2026-02-06 10:49:56 +08:00

289 lines
6.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="word-cloud-container">
<!-- uni-app / 小程序 canvas2d 模式 -->
<canvas
type="2d"
class="word-cloud-canvas"
:style="{ width: width + 'px', height: height + 'px' }"
></canvas>
</view>
</template>
<script>
export default {
name: 'WordCloud',
props: {
// 词云数据:[{ text: '关键词', value: 100 }]
wordData: {
type: Array,
default: () => []
},
// 画布宽度
width: {
type: Number,
default: 300
},
// 画布高度
height: {
type: Number,
default: 300
},
// 颜色:外 / 中 / 内
colorList: {
type: Array,
default: () => ['#60A5FA', '#FEC200', '#EF4444']
}
},
data() {
return {
ctx: null, // canvas 2d 上下文
placedWords: [], // 已放置文字的包围盒(用于碰撞检测)
centerWords: [], // value 最大的 3 个
otherWords: [] // 其余词
};
},
watch: {
// 词云数据变化时重绘
wordData: {
deep: true,
handler() {
this.drawWordCloud();
}
}
},
mounted() {
this.initCanvas();
},
methods: {
// 初始化 canvas
async initCanvas() {
// 延迟,确保 canvas 已渲染(小程序必需)
await new Promise(r => setTimeout(r, 50));
const query = uni.createSelectorQuery().in(this);
query
.select('.word-cloud-canvas')
.fields({ node: true })
.exec(res => {
if (!res || !res[0] || !res[0].node) return;
const canvas = res[0].node;
const ctx = canvas.getContext('2d');
// 适配高清屏
const dpr = uni.getSystemInfoSync().pixelRatio || 1;
canvas.width = this.width * dpr;
canvas.height = this.height * dpr;
ctx.scale(dpr, dpr);
this.ctx = ctx;
this.drawWordCloud();
});
},
// 绘制词云
drawWordCloud() {
if (!this.ctx || !this.wordData.length) return;
// 清空画布
this.ctx.clearRect(0, 0, this.width, this.height);
this.placedWords = [];
// 按 value 从大到小排序
const sorted = [...this.wordData].sort((a, b) => b.value - a.value);
// 中心层:取前 3 个
this.centerWords = sorted.slice(0, 3);
this.otherWords = sorted.slice(3);
// 先画中心层(优先级最高)
this.centerWords.forEach((word, index) => {
this.placeWord(word, 'center', index);
});
// 再画中 / 外层
this.otherWords.forEach(word => {
this.placeWord(word, 'other');
});
// ⭐ 通知父组件canvas 绘制完成
this.$emit('rendered');
},
// 放置单个词
placeWord(word, type, index = 0) {
const ctx = this.ctx;
const text = word.text || word.name;
const maxAttempts = 200;
let fontSize = 24;
let layer = 'middle';
// 根据层级设置字号
if (type === 'center') {
fontSize = 32; // ⭐ 最中间三个32px
layer = 'center';
} else {
layer = Math.random() > 0.5 ? 'middle' : 'outer';
fontSize = layer === 'outer' ? 18 : 24;
}
// 随机两档旋转角度
const angleLimit =
Math.random() > 0.5
? 60 * Math.PI / 180
: 30 * Math.PI / 180;
const angle = (Math.random() - 0.5) * 2 * angleLimit;
// 设置字体(全部加粗)
ctx.font = `bold ${fontSize}px sans-serif`;
// 测量文字尺寸
const textWidth = ctx.measureText(text).width;
const textHeight = fontSize * 1.05;
for (let i = 0; i < maxAttempts; i++) {
let x, y;
// ===== 中心层:固定布局,保证 3 个一定能放下 =====
if (layer === 'center') {
const centerX = this.width / 2;
const centerY = this.height / 2;
// 中心三个固定位置
const offsets = [
{ x: 0, y: 0 }, // 最大的,正中
{ x: -80, y: 0 }, // 左
{ x: 80, y: 0 } // 右
];
const pos = offsets[index] || offsets[0];
x = centerX + pos.x;
y = centerY + pos.y;
} else {
// 中 / 外层:随机位置
x = this.width * 0.05 + Math.random() * this.width * 0.9;
y = this.height * 0.05 + Math.random() * this.height * 0.9;
}
// 计算旋转后的包围盒
const rect = this.getBoundingRect(
x,
y,
textWidth,
textHeight,
angle,
2
);
// 外层:超出画布直接跳过,继续尝试
if (layer === 'outer') {
if (
rect.left < 0 ||
rect.right > this.width ||
rect.top < 0 ||
rect.bottom > this.height
) {
continue;
}
}
// 碰撞检测
if (this.checkOverlapRect(rect)) continue;
// 根据层级设置颜色
ctx.fillStyle =
layer === 'center'
? this.colorList[2]
: layer === 'middle'
? this.colorList[1]
: this.colorList[0];
// 绘制文字
this.drawText(text, x, y, angle);
// 保存包围盒
this.placedWords.push({ rect });
break;
}
},
// 碰撞检测AABB
checkOverlapRect(current) {
for (const item of this.placedWords) {
const r = item.rect;
if (
current.left < r.right &&
current.right > r.left &&
current.top < r.bottom &&
current.bottom > r.top
) {
return true;
}
}
return false;
},
// 计算旋转后的包围盒
getBoundingRect(x, y, width, height, angle, gap = 2) {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const halfW = (width - gap) / 2;
const halfH = (height - gap) / 2;
const points = [
{ x: -halfW, y: -halfH },
{ x: halfW, y: -halfH },
{ x: halfW, y: halfH },
{ x: -halfW, y: halfH }
];
const xs = [];
const ys = [];
points.forEach(p => {
xs.push(x + p.x * cos - p.y * sin);
ys.push(y + p.x * sin + p.y * cos);
});
return {
left: Math.min(...xs),
right: Math.max(...xs),
top: Math.min(...ys),
bottom: Math.max(...ys)
};
},
// 绘制文字
drawText(text, x, y, angle) {
const ctx = this.ctx;
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, 0, 0);
ctx.restore();
}
}
};
</script>
<style scoped>
.word-cloud-container {
width: 100%;
height: 100%;
}
.word-cloud-canvas {
width: 100%;
height: 100%;
}
</style>