Files
JiaZhiQianYan/components/WordCloud/WordCloud.vue
2026-01-31 16:59:36 +08:00

307 lines
10 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">
<!-- canvas组件适配小程序 -->
<canvas
type="2d"
ref="wordCloudCanvas"
id="wordCloudCanvas"
class="word-cloud-canvas"
:style="{width: canvasWidth + 'px', height: canvasHeight + 'px'}"
></canvas>
</view>
</template>
<script>
export default {
name: "WordCloud",
props: {
// 词云数据 [{text: '关键词', value: 100}, ...]
wordData: {
type: Array,
required: true,
default: () => []
},
// 画布宽度
width: {
type: Number,
default: 300
},
// 画布高度
height: {
type: Number,
default: 300
},
// 文字颜色列表(分层配色)
colorList: {
type: Array,
default: () => ['#60A5FA', '#FEC200', '#EF4444'] // 外圈、中间、中心
},
// 新增:字号配置,让组件更灵活
fontSizeConfig: {
type: Object,
default: () => ({
minSize: 12, // 最小字号
maxSize: 40, // 最大字号
scaleFactor: 0.1 // 缩放因子,越大字号差异越明显
})
}
},
data() {
return {
canvasWidth: this.width,
canvasHeight: this.height,
ctx: null, // canvas 2d 上下文
placedWords: [] // 已放置的文字信息(用于碰撞检测)
};
},
watch: {
wordData: {
handler() {
this.drawWordCloud();
},
deep: true
}
},
mounted() {
this.initCanvas();
},
methods: {
// 初始化canvas
async initCanvas() {
// 修复点1增加延迟确保canvas节点渲染完成兼容小程序渲染时机
await new Promise(resolve => setTimeout(resolve, 50));
// 适配小程序获取canvas上下文
const query = uni.createSelectorQuery().in(this);
// 修复点2使用ref选择器.ref-wordCloudCanvas替代ID选择器或给canvas加id
query.select('.word-cloud-canvas') // 改用class选择器与模板中canvas的class对应
.fields({ node: true, size: true })
.exec(async (res) => {
// 修复点3增加空值判断避免res[0]为null时报错
if (!res || !res[0] || !res[0].node) {
console.error('获取canvas节点失败请检查canvas是否正确渲染');
return;
}
const canvas = res[0].node;
let ctx = null;
// 修复点4兼容不同小程序平台的canvas 2d上下文获取
try {
ctx = canvas.getContext('2d');
} catch (e) {
console.warn('获取2d上下文失败尝试兼容处理', e);
// 部分小程序平台需要先初始化canvas 2d
ctx = uni.createCanvasContext('wordCloudCanvas', this);
}
if (!ctx) {
console.error('无法获取canvas 2d上下文');
return;
}
// 设置canvas尺寸
const dpr = uni.getSystemInfoSync().pixelRatio || 1;
canvas.width = this.canvasWidth * dpr;
canvas.height = this.canvasHeight * dpr;
ctx.scale(dpr, dpr);
this.ctx = ctx;
this.drawWordCloud();
});
},
// 绘制词云核心方法
drawWordCloud() {
if (!this.ctx || !this.wordData.length) return;
// 清空画布和已放置文字记录
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
this.placedWords = [];
// 按权重排序(权重越大,文字越大)
const sortedWords = [...this.wordData].sort((a, b) => b.value - a.value);
// 计算value的最大值和最小值用于归一化
const values = sortedWords.map(item => item.value);
this.valueMax = Math.max(...values);
this.valueMin = Math.min(...values);
// 逐个绘制文字
sortedWords.forEach((word, index) => {
this.placeWord(word, index);
});
},
// 放置单个文字(核心:碰撞检测,确保不重叠)
placeWord(word, index) {
const ctx = this.ctx;
// 优化1提高最大尝试次数从50→150让更多文字能找到位置
const maxAttempts = 150;
const { minSize, maxSize, scaleFactor } = this.fontSizeConfig;
// ===== 核心优化:重新设计字号计算逻辑 =====
// 1. 归一化value0-1区间
let normalizedValue = 1;
if (this.valueMax !== this.valueMin) {
normalizedValue = (word.value - this.valueMin) / (this.valueMax - this.valueMin);
}
// 2. 计算字号:基于归一化值,确保最小到最大的平滑过渡
// 公式:最小字号 + (最大字号 - 最小字号) * 归一化值 * 缩放因子
const fontSize = Math.min(
minSize + (maxSize - minSize) * normalizedValue * scaleFactor,
maxSize
);
// ==========================================
// 旋转角度:-60° 到 60°
const rotateAngle = (Math.random() - 0.5) * 120 * Math.PI / 180;
// 设置字体样式
ctx.font = `${fontSize}px sans-serif`;
// 获取文字宽度和高度(用于碰撞检测)
const textWidth = ctx.measureText(word.text || word.name).width; // 兼容text/name字段
// 优化3更精准的文字高度估算从1.2→1.05),减少无效空间
const textHeight = fontSize * 1.05;
// 尝试放置文字,直到找到不重叠的位置
for (let i = 0; i < maxAttempts; i++) {
// 优化4扩大随机位置范围从0.2-0.8→0.05-0.95),利用边缘空间
const x = this.canvasWidth * 0.05 + Math.random() * this.canvasWidth * 0.9;
const y = this.canvasHeight * 0.05 + Math.random() * this.canvasHeight * 0.9;
// 碰撞检测:检查当前位置是否与已放置的文字重叠(传入间距容差)
const isOverlap = this.checkOverlap(x, y, textWidth, textHeight, rotateAngle, 2); // 间距容差2px
if (!isOverlap) {
// ===== 核心修改:按位置分层设置颜色 =====
// 1. 计算画布中心坐标
const centerX = this.canvasWidth / 2;
const centerY = this.canvasHeight / 2;
// 2. 计算当前文字位置到中心的距离(欧几里得距离)
const distance = Math.sqrt(Math.pow(x - centerX, 2) + Math.pow(y - centerY, 2));
// 3. 计算画布最大半径(中心到角落的距离)
const maxDistance = Math.sqrt(Math.pow(centerX, 2) + Math.pow(centerY, 2));
// 4. 按距离分三层分配颜色
let color;
if (distance > maxDistance * 0.66) {
// 外圈(距离>2/3最大半径#60A5FA
color = this.colorList[0];
} else if (distance > maxDistance * 0.33) {
// 中间层(距离>1/3最大半径#FEC200
color = this.colorList[1];
} else {
// 中心层距离≤1/3最大半径#EF4444
color = this.colorList[2];
}
// =========================================
// 设置文字颜色
ctx.fillStyle = color;
// 无重叠,绘制文字
this.drawTextAtPosition(word.text || word.name, x, y, rotateAngle, fontSize);
// 记录已放置的文字信息(用于后续碰撞检测)
this.placedWords.push({
x, y, width: textWidth, height: textHeight, angle: rotateAngle
});
break;
}
}
},
// 碰撞检测:检查当前文字是否与已放置的文字重叠(新增间距容差参数)
checkOverlap(x, y, width, height, angle, gap = 2) {
// 简化碰撞检测:使用包围盒检测(旋转后的矩形包围盒)
const currentRect = this.getBoundingRect(x, y, width, height, angle, gap);
for (const placed of this.placedWords) {
const placedRect = this.getBoundingRect(placed.x, placed.y, placed.width, placed.height, placed.angle, gap);
// 轴对齐包围盒AABB碰撞检测
if (
currentRect.left < placedRect.right &&
currentRect.right > placedRect.left &&
currentRect.top < placedRect.bottom &&
currentRect.bottom > placedRect.top
) {
return true; // 重叠
}
}
return false; // 不重叠
},
// 获取旋转后文字的包围盒(新增间距容差参数,缩小包围盒)
getBoundingRect(x, y, width, height, angle, gap = 2) {
// 计算旋转后的四个顶点
const cos = Math.cos(angle);
const sin = Math.sin(angle);
// 优化5缩小包围盒减去间距容差让文字间距更小
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 rotatedPoints = points.map(point => ({
x: x + point.x * cos - point.y * sin,
y: y + point.x * sin + point.y * cos
}));
// 计算包围盒的最小/最大坐标
const left = Math.min(...rotatedPoints.map(p => p.x));
const right = Math.max(...rotatedPoints.map(p => p.x));
const top = Math.min(...rotatedPoints.map(p => p.y));
const bottom = Math.max(...rotatedPoints.map(p => p.y));
return { left, right, top, bottom };
},
// 在指定位置绘制旋转后的文字
drawTextAtPosition(text, x, y, angle, fontSize) {
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>