涨停分析,异常数据对接,词云优化

This commit is contained in:
renzhijun
2026-02-06 10:49:28 +08:00
parent 21e16f543f
commit 8445e95ba3
3 changed files with 315 additions and 339 deletions

View File

@@ -1,24 +1,22 @@
<template> <template>
<view class="word-cloud-container"> <view class="word-cloud-container">
<!-- canvas组件适配小程序 --> <!-- uni-app / 小程序 canvas2d 模式 -->
<canvas <canvas
type="2d" type="2d"
ref="wordCloudCanvas" class="word-cloud-canvas"
id="wordCloudCanvas" :style="{ width: width + 'px', height: height + 'px' }"
class="word-cloud-canvas" ></canvas>
:style="{width: canvasWidth + 'px', height: canvasHeight + 'px'}"
></canvas>
</view> </view>
</template> </template>
<script> <script>
export default { export default {
name: "WordCloud", name: 'WordCloud',
props: { props: {
// 词云数据 [{text: '关键词', value: 100}, ...] // 词云数据[{ text: '关键词', value: 100 }]
wordData: { wordData: {
type: Array, type: Array,
required: true,
default: () => [] default: () => []
}, },
// 画布宽度 // 画布宽度
@@ -31,263 +29,246 @@ export default {
type: Number, type: Number,
default: 300 default: 300
}, },
// 文字颜色列表(分层配色) // 颜色:外 / 中 / 内
colorList: { colorList: {
type: Array, type: Array,
default: () => ['#60A5FA', '#FEC200', '#EF4444'] // 外圈、中间、中心 default: () => ['#60A5FA', '#FEC200', '#EF4444']
},
// 新增:字号配置,让组件更灵活
fontSizeConfig: {
type: Object,
default: () => ({
minSize: 12, // 最小字号
maxSize: 40, // 最大字号
scaleFactor: 0.1 // 缩放因子,越大字号差异越明显
})
} }
}, },
data() { data() {
return { return {
canvasWidth: this.width, ctx: null, // canvas 2d 上下文
canvasHeight: this.height, placedWords: [], // 已放置文字的包围盒(用于碰撞检测)
ctx: null, // canvas 2d 上下文 centerWords: [], // value 最大的 3 个
placedWords: [] // 已放置的文字信息(用于碰撞检测) otherWords: [] // 其余词
}; };
}, },
watch: { watch: {
// 词云数据变化时重绘
wordData: { wordData: {
deep: true,
handler() { handler() {
this.drawWordCloud(); this.drawWordCloud();
}, }
deep: true
} }
}, },
mounted() { mounted() {
this.initCanvas(); this.initCanvas();
}, },
methods: { methods: {
// 初始化canvas // 初始化 canvas
async initCanvas() { async initCanvas() {
// 修复点1增加延迟确保canvas节点渲染完成(兼容小程序渲染时机 // 延迟,确保 canvas 已渲染(小程序必需
await new Promise(resolve => setTimeout(resolve, 50)); await new Promise(r => setTimeout(r, 50));
// 适配小程序获取canvas上下文
const query = uni.createSelectorQuery().in(this); const query = uni.createSelectorQuery().in(this);
// 修复点2使用ref选择器.ref-wordCloudCanvas替代ID选择器或给canvas加id query
query.select('.word-cloud-canvas') // 改用class选择器与模板中canvas的class对应 .select('.word-cloud-canvas')
.fields({ node: true, size: true }) .fields({ node: true })
.exec(async (res) => { .exec(res => {
// 修复点3增加空值判断避免res[0]为null时报错 if (!res || !res[0] || !res[0].node) return;
if (!res || !res[0] || !res[0].node) {
console.error('获取canvas节点失败请检查canvas是否正确渲染');
return;
}
const canvas = res[0].node; const canvas = res[0].node;
let ctx = null; const ctx = canvas.getContext('2d');
// 修复点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; const dpr = uni.getSystemInfoSync().pixelRatio || 1;
canvas.width = this.canvasWidth * dpr; canvas.width = this.width * dpr;
canvas.height = this.canvasHeight * dpr; canvas.height = this.height * dpr;
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
this.ctx = ctx; this.ctx = ctx;
this.drawWordCloud(); this.drawWordCloud();
}); });
}, },
// 绘制词云核心方法 // 绘制词云
drawWordCloud() { drawWordCloud() {
if (!this.ctx || !this.wordData.length) return; if (!this.ctx || !this.wordData.length) return;
// 清空画布和已放置文字记录 // 清空画布
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight); this.ctx.clearRect(0, 0, this.width, this.height);
this.placedWords = []; this.placedWords = [];
// 按权重排序(权重越大,文字越大) // 按 value 从大到小排序
const sortedWords = [...this.wordData].sort((a, b) => b.value - a.value); const sorted = [...this.wordData].sort((a, b) => b.value - a.value);
// 计算value的最大值和最小值用于归一化 // 中心层:取前 3 个
const values = sortedWords.map(item => item.value); this.centerWords = sorted.slice(0, 3);
this.valueMax = Math.max(...values); this.otherWords = sorted.slice(3);
this.valueMin = Math.min(...values);
// 先画中心层(优先级最高)
// 逐个绘制文字 this.centerWords.forEach((word, index) => {
sortedWords.forEach((word, index) => { this.placeWord(word, 'center', index);
this.placeWord(word, index);
}); });
// 再画中 / 外层
this.otherWords.forEach(word => {
this.placeWord(word, 'other');
});
// ⭐ 通知父组件canvas 绘制完成
this.$emit('rendered');
}, },
// 放置单个文字(核心:碰撞检测,确保不重叠) // 放置单个
placeWord(word, index) { placeWord(word, type, index = 0) {
const ctx = this.ctx; const ctx = this.ctx;
// 优化1提高最大尝试次数从50→150让更多文字能找到位置 const text = word.text || word.name;
const maxAttempts = 150; const maxAttempts = 200;
const { minSize, maxSize, scaleFactor } = this.fontSizeConfig;
let fontSize = 24;
// ===== 核心优化:重新设计字号计算逻辑 ===== let layer = 'middle';
// 1. 归一化value0-1区间
let normalizedValue = 1; // 根据层级设置字号
if (this.valueMax !== this.valueMin) { if (type === 'center') {
normalizedValue = (word.value - this.valueMin) / (this.valueMax - this.valueMin); fontSize = 32; // ⭐ 最中间三个32px
layer = 'center';
} else {
layer = Math.random() > 0.5 ? 'middle' : 'outer';
fontSize = layer === 'outer' ? 18 : 24;
} }
// 2. 计算字号:基于归一化值,确保最小到最大的平滑过渡 // 随机两档旋转角度
// 公式:最小字号 + (最大字号 - 最小字号) * 归一化值 * 缩放因子 const angleLimit =
const fontSize = Math.min( Math.random() > 0.5
minSize + (maxSize - minSize) * normalizedValue * scaleFactor, ? 60 * Math.PI / 180
maxSize : 30 * Math.PI / 180;
);
// ========================================== const angle = (Math.random() - 0.5) * 2 * angleLimit;
// 旋转角度:-60° 到 60° // 设置字体(全部加粗)
const rotateAngle = (Math.random() - 0.5) * 120 * Math.PI / 180; ctx.font = `bold ${fontSize}px sans-serif`;
// 设置字体样式 // 测量文字尺寸
ctx.font = `${fontSize}px sans-serif`; const textWidth = ctx.measureText(text).width;
const textHeight = fontSize * 1.05;
// 获取文字宽度和高度(用于碰撞检测)
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++) { for (let i = 0; i < maxAttempts; i++) {
// 优化4扩大随机位置范围从0.2-0.8→0.05-0.95),利用边缘空间 let x, y;
const x = this.canvasWidth * 0.05 + Math.random() * this.canvasWidth * 0.9;
const y = this.canvasHeight * 0.05 + Math.random() * this.canvasHeight * 0.9; // ===== 中心层:固定布局,保证 3 个一定能放下 =====
if (layer === 'center') {
// 碰撞检测:检查当前位置是否与已放置的文字重叠(传入间距容差) const centerX = this.width / 2;
const isOverlap = this.checkOverlap(x, y, textWidth, textHeight, rotateAngle, 2); // 间距容差2px const centerY = this.height / 2;
if (!isOverlap) { // 中心三个固定位置
// ===== 核心修改:按位置分层设置颜色 ===== const offsets = [
// 1. 计算画布中心坐标 { x: 0, y: 0 }, // 最大的,正中
const centerX = this.canvasWidth / 2; { x: -80, y: 0 }, // 左
const centerY = this.canvasHeight / 2; { x: 80, y: 0 } // 右
];
// 2. 计算当前文字位置到中心的距离(欧几里得距离)
const distance = Math.sqrt(Math.pow(x - centerX, 2) + Math.pow(y - centerY, 2)); const pos = offsets[index] || offsets[0];
x = centerX + pos.x;
// 3. 计算画布最大半径(中心到角落的距离) y = centerY + pos.y;
const maxDistance = Math.sqrt(Math.pow(centerX, 2) + Math.pow(centerY, 2)); } else {
// 中 / 外层:随机位置
// 4. 按距离分三层分配颜色 x = this.width * 0.05 + Math.random() * this.width * 0.9;
let color; y = this.height * 0.05 + Math.random() * this.height * 0.9;
if (distance > maxDistance * 0.66) { }
// 外圈(距离>2/3最大半径#60A5FA
color = this.colorList[0]; // 计算旋转后的包围盒
} else if (distance > maxDistance * 0.33) { const rect = this.getBoundingRect(
// 中间层(距离>1/3最大半径#FEC200 x,
color = this.colorList[1]; y,
} else { textWidth,
// 中心层距离≤1/3最大半径#EF4444 textHeight,
color = this.colorList[2]; angle,
2
);
// 外层:超出画布直接跳过,继续尝试
if (layer === 'outer') {
if (
rect.left < 0 ||
rect.right > this.width ||
rect.top < 0 ||
rect.bottom > this.height
) {
continue;
} }
// =========================================
// 设置文字颜色
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;
} }
// 碰撞检测
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
checkOverlap(x, y, width, height, angle, gap = 2) { checkOverlapRect(current) {
// 简化碰撞检测:使用包围盒检测(旋转后的矩形包围盒) for (const item of this.placedWords) {
const currentRect = this.getBoundingRect(x, y, width, height, angle, gap); const r = item.rect;
for (const placed of this.placedWords) {
const placedRect = this.getBoundingRect(placed.x, placed.y, placed.width, placed.height, placed.angle, gap);
// 轴对齐包围盒AABB碰撞检测
if ( if (
currentRect.left < placedRect.right && current.left < r.right &&
currentRect.right > placedRect.left && current.right > r.left &&
currentRect.top < placedRect.bottom && current.top < r.bottom &&
currentRect.bottom > placedRect.top current.bottom > r.top
) { ) {
return true; // 重叠 return true;
} }
} }
return false; // 不重叠 return false;
}, },
// 获取旋转后文字的包围盒(新增间距容差参数,缩小包围盒) // 计算旋转后的包围盒
getBoundingRect(x, y, width, height, angle, gap = 2) { getBoundingRect(x, y, width, height, angle, gap = 2) {
// 计算旋转后的四个顶点
const cos = Math.cos(angle); const cos = Math.cos(angle);
const sin = Math.sin(angle); const sin = Math.sin(angle);
// 优化5缩小包围盒减去间距容差让文字间距更小
const halfW = (width - gap) / 2; const halfW = (width - gap) / 2;
const halfH = (height - gap) / 2; const halfH = (height - gap) / 2;
// 四个顶点坐标(相对于中心)
const points = [ const points = [
{ x: -halfW, y: -halfH }, { x: -halfW, y: -halfH },
{ x: -halfW, y: halfH }, { x: halfW, y: -halfH },
{ x: halfW, y: halfH }, { x: halfW, y: halfH },
{ x: halfW, y: -halfH } { x: -halfW, y: halfH }
]; ];
// 旋转并平移到实际位置 const xs = [];
const rotatedPoints = points.map(point => ({ const ys = [];
x: x + point.x * cos - point.y * sin,
y: y + point.x * sin + point.y * cos points.forEach(p => {
})); xs.push(x + p.x * cos - p.y * sin);
ys.push(y + p.x * sin + p.y * cos);
// 计算包围盒的最小/最大坐标 });
const left = Math.min(...rotatedPoints.map(p => p.x));
const right = Math.max(...rotatedPoints.map(p => p.x)); return {
const top = Math.min(...rotatedPoints.map(p => p.y)); left: Math.min(...xs),
const bottom = Math.max(...rotatedPoints.map(p => p.y)); right: Math.max(...xs),
top: Math.min(...ys),
return { left, right, top, bottom }; bottom: Math.max(...ys)
};
}, },
// 在指定位置绘制旋转后的文字 // 绘制文字
drawTextAtPosition(text, x, y, angle, fontSize) { drawText(text, x, y, angle) {
const ctx = this.ctx; const ctx = this.ctx;
// 保存当前画布状态
ctx.save(); ctx.save();
// 平移到文字中心位置
ctx.translate(x, y); ctx.translate(x, y);
// 旋转
ctx.rotate(angle); ctx.rotate(angle);
// 绘制文字(居中对齐)
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.fillText(text, 0, 0); ctx.fillText(text, 0, 0);
// 恢复画布状态
ctx.restore(); ctx.restore();
} }
} }
@@ -299,6 +280,7 @@ export default {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.word-cloud-canvas { .word-cloud-canvas {
width: 100%; width: 100%;
height: 100%; height: 100%;

View File

@@ -101,7 +101,7 @@
color: 'white', color: 'white',
fontSize: '24rpx', fontSize: '24rpx',
fontWeight: '500' fontWeight: '500'
}" > }" @click="bkydAction(index)" >
<view class="single-line-ellipsis">{{item.title}}</view> <view class="single-line-ellipsis">{{item.title}}</view>
<view class="count-text">{{item.count}}</view> <view class="count-text">{{item.count}}</view>
@@ -138,7 +138,7 @@
<view v-show="activeType === 0" style="width: 100%; height: 500rpx;"> <view v-show="activeType === 0" style="width: 100%; height: 500rpx;">
<l-echart ref="chartRef"></l-echart> <l-echart ref="chartRef"></l-echart>
</view> </view>
<WordCloud v-show="activeType === 1" :wordData="wordData" :width="330" :height="330" /> <WordCloud v-show="activeType === 1" :wordData="wordData" :width="330" :height="330" @rendered="onWordCloudRendered" />
</view> </view>
@@ -556,6 +556,10 @@
//} //}
}, },
methods: { methods: {
// 词云绘制完成
onWordCloudRendered() {
uni.hideLoading();
},
getStockHeatType(stock) { getStockHeatType(stock) {
// 假设通过连板数计算热度(可根据实际业务逻辑调整) // 假设通过连板数计算热度(可根据实际业务逻辑调整)
const days = stock.continuous_days_num || 0; const days = stock.continuous_days_num || 0;
@@ -725,9 +729,19 @@ getStockHeatType(stock) {
break; break;
case 1: case 1:
//this.$refs.chartRef && this.initPieChart(); // 增加存在性判断 //this.$refs.chartRef && this.initPieChart(); // 增加存在性判断
uni.showLoading({
title: '词云生成中...',
mask: true // 防止用户误触
});
this.initWordCloud(); this.initWordCloud();
break; break;
case 2: case 2:
uni.showLoading({
title: '词云生成中...',
mask: true // 防止用户误触
});
this.initWordCloud(); this.initWordCloud();
break; break;
} }
@@ -916,7 +930,9 @@ getStockHeatType(stock) {
}]; }];
} }
setTimeout(() => {
uni.hideLoading();
}, 2000);
//console.log('父页面设置词云数据:', JSON.stringify(this.wordData)); //console.log('父页面设置词云数据:', JSON.stringify(this.wordData));
}, },

View File

@@ -188,48 +188,40 @@
} }
}, },
computed: { computed: {
// 筛选后的股票列表 // 筛选后的股票列表按板块codes匹配 + 连板排序/筛选
filteredStocks() { filteredStocks() {
if (!this.allStocks.length) return []; if (!this.originData?.stocks || !this.bkList.length) return [];
let stocks = [...this.allStocks]; // 1. 获取当前选中板块的股票代码集合
const currentBk = this.bkList[this.activeIndex];
// 先筛选当前选中板块的股票 if (!currentBk?.codes || currentBk.codes.length === 0) return [];
if (this.activeIndex >= 0 && this.bkList.length) { const targetCodes = new Set(currentBk.codes); // 转Set提升匹配效率
const currentSector = this.bkList[this.activeIndex]?.title;
if (currentSector) { // 2. 从stocks中筛选出scode在targetCodes中的股票
stocks = stocks.filter(stock => { let stocks = this.originData.stocks.filter(stock => targetCodes.has(stock.scode));
// 匹配核心板块或板块分类
const sectorMatch = stock.core_sectors.some(s => s.includes(currentSector)) || // 3. 保留原有筛选/排序逻辑
(Array.isArray(stock.sector_category) ? stock.sector_category.includes(currentSector) : stock.sector_category === currentSector); switch (this.filterIndex) {
return sectorMatch; case 0: // 按连板数从高到低排序
}); stocks.sort((a, b) => {
} const aDays = this.parseContinuousDays(a.continuous_days);
} const bDays = this.parseContinuousDays(b.continuous_days);
return bDays - aDays;
// 根据筛选类型排序/过滤 });
switch (this.filterIndex) { break;
case 0: // 按连板数 case 1: // 只看龙头≥2连板并按连板数从高到低排序
stocks.sort((a, b) => { stocks = stocks.filter(stock => this.parseContinuousDays(stock.continuous_days) >= 2);
const aDays = this.parseContinuousDays(a.continuous_days); stocks.sort((a, b) => {
const bDays = this.parseContinuousDays(b.continuous_days); const aDays = this.parseContinuousDays(a.continuous_days);
return bDays - aDays; const bDays = this.parseContinuousDays(b.continuous_days);
}); return bDays - aDays;
break; });
case 1: // 只看龙头≥2连板 break;
stocks = stocks.filter(stock => this.parseContinuousDays(stock.continuous_days) >= 2); }
stocks.sort((a, b) => {
const aDays = this.parseContinuousDays(a.continuous_days); return stocks;
const bDays = this.parseContinuousDays(b.continuous_days); }
return bDays - aDays; },
});
break;
}
return stocks;
}
},
onLoad(e) { onLoad(e) {
this.activeIndex = e.index this.activeIndex = e.index
@@ -351,99 +343,85 @@ computed: {
}, },
// 为所有股票添加角色标签
setStockRoles() { // 为所有股票添加角色标签
if (!this.originData || !this.originData.stocks || !this.bkList.length) return; setStockRoles() {
console.log("setStockRoles",JSON.stringify(this.originData.stocks)) if (!this.originData || !this.originData.stocks || !this.bkList.length) return;
this.allStocks = this.originData.stocks.map(stock => { this.allStocks = this.originData.stocks.map(stock => {
// 找到股票所属板块的热度排名 // 找到股票所属板块的热度排名
let sectorIndex = -1; let sectorIndex = -1;
const stockSectors = Array.isArray(stock.sector_category) ? stock.sector_category : [stock.sector_category]; const stockSectors = Array.isArray(stock.sector_category) ? stock.sector_category : [stock.sector_category];
// 匹配板块列表中的位置
// 匹配板块列表中的位置 this.bkList.some((bk, idx) => {
this.bkList.some((bk, idx) => { const match = stockSectors.some(s => s.includes(bk.title));
const match = stockSectors.some(s => s.includes(bk.title)); if (match) {
if (match) { sectorIndex = idx;
sectorIndex = idx; return true;
return true; }
} return false;
return false; });
}); // 获取同板块的所有股票
const sectorStocks = this.originData.stocks.filter(s => {
// 获取同板块的所有股票 const sSectors = Array.isArray(s.sector_category) ? s.sector_category : [s.sector_category];
const sectorStocks = this.originData.stocks.filter(s => { return sSectors.some(ss => stockSectors.includes(ss));
const sSectors = Array.isArray(s.sector_category) ? s.sector_category : [s.sector_category]; });
return sSectors.some(ss => stockSectors.includes(ss)); // 获取股票角色
}); const stockRole = this.getStockRole(stock, sectorStocks, sectorIndex);
return {
// 获取股票角色 ...stock,
const stockRole = this.getStockRole(stock, sectorStocks, sectorIndex); stockRole: stockRole.text ? stockRole : null
};
return { });
...stock, },
stockRole: stockRole.text ? stockRole : null
};
});
},
/** /**
* 请求接口数据(优化:动态日期+自动时间戳) * 请求接口数据(优化:动态日期+自动时间戳)
*/ */
// 请求接口数据 // 请求接口数据
async fetchData() { async fetchData() {
try { try {
const timestamp = new Date().getTime(); const timestamp = new Date().getTime();
const formattedDate = this.selectedFullDate; const formattedDate = this.selectedFullDate;
const baseURL = getBaseURL1(); const baseURL = getBaseURL1();
const requestUrl = `${baseURL}/data/zt/daily/${formattedDate}.json?t=${timestamp}`; const requestUrl = `${baseURL}/data/zt/daily/${formattedDate}.json?t=${timestamp}`;
console.log('请求URL', requestUrl); console.log('请求URL', requestUrl);
const res = await uni.request({
const res = await uni.request({ url: requestUrl,
url: requestUrl, method: 'GET'
method: 'GET' });
});
if (res.statusCode === 200 && res.data) {
if (res.statusCode === 200 && res.data) { this.originData = res.data;
this.originData = res.data; const { sector_data } = this.originData;
// 处理板块列表 // 解析sector_data生成板块列表剔除「其他」格式[{title: 板块名, codes: 股票代码数组}]
const chartData = this.originData.chart_data || {}; this.bkList = Object.entries(sector_data)
const labels = chartData.labels || []; .filter(([sectorName]) => sectorName !== '其他') // 去掉其他板块
const counts = chartData.counts || []; .map(([sectorName, sectorInfo]) => ({
title: sectorName,
const maxCount = counts.length > 0 ? Math.max(...counts) : 0; codes: sectorInfo.stock_codes || [] // 取板块对应的股票代码
const maxLen = Math.min(labels.length, counts.length); }));
let bkList = [];
console.log('生成板块列表:', this.bkList);
for (let i = 0; i < maxLen; i++) { // 为股票添加角色标签
const title = labels[i]; this.setStockRoles();
const count = counts[i] || 0; } else {
bkList.push({ uni.showToast({
title, title: '数据请求失败',
count icon: 'none'
}); });
} }
} catch (error) {
this.bkList = bkList; console.error('请求异常:', error);
uni.showToast({
// 为股票添加角色标签 title: '网络异常',
this.setStockRoles(); icon: 'none'
} else { });
uni.showToast({ }
title: '数据请求失败', },
icon: 'none'
});
}
} catch (error) {
console.error('请求异常:', error);
uni.showToast({
title: '网络异常',
icon: 'none'
});
}
},
} }
} }
</script> </script>