307 lines
10 KiB
Vue
307 lines
10 KiB
Vue
<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. 归一化value(0-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>
|