289 lines
6.8 KiB
Vue
289 lines
6.8 KiB
Vue
<template>
|
||
<view class="word-cloud-container">
|
||
<!-- uni-app / 小程序 canvas,2d 模式 -->
|
||
<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>
|