更新一版热力图

This commit is contained in:
kron
2025-09-28 18:28:49 +08:00
parent 889e87d3e9
commit 9c6824b82f
3 changed files with 168 additions and 80 deletions

105
src/heatmap.js Normal file
View File

@@ -0,0 +1,105 @@
/**
* 在 uni-app 小程序里画弓箭热力图
* @param {String} canvasId 画布 id
* @param {Number} width 画布宽px
* @param {Number} height 画布高px
* @param {Array} arrowData [{x, y, count}, ...]
*/
export function generateHeatmapImage(canvasId, width, height, arrowData) {
return new Promise((resolve, reject) => {
// 1. 创建绘图上下文
const ctx = uni.createCanvasContext(canvasId);
// 3. 计算最大 count用于归一化
const maxCount = Math.max(...arrowData.map((p) => p.count), 1);
// 4. 热点半径:可按实际靶子大小调,这里取画布短边的 6%
const radius = Math.min(width, height) * 0.12;
// 5. 按count从小到大排序count越大越后面
arrowData.sort((a, b) => a.count - b.count);
// 6. 画每个点
arrowData.forEach((item) => {
const intensity = item.count / maxCount; // 0-1
// console.log(item.count, maxCount, intensity);
const r = radius * (1.2 - intensity * 0.8);
// 创建径向渐变
const grd = ctx.createCircularGradient(
item.x * width,
item.y * height,
r
);
grd.addColorStop(0, heatColor(intensity, 1));
grd.addColorStop(0.5, heatColor(intensity, 0.6));
grd.addColorStop(1, "rgba(0,0,0,0)");
ctx.save();
ctx.fillStyle = grd;
ctx.globalCompositeOperation = "screen"; // 叠加变亮
ctx.beginPath();
ctx.arc(item.x * width, item.y * height, r, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
});
// 6. 可选:整体蒙版,让非热点区域暗下去
// ctx.save()
// ctx.fillStyle = 'rgba(0,0,0,0.35)'
// ctx.fillRect(0, 0, width, height)
// ctx.restore()
// 7. 把指令一次性推送到 canvas
ctx.draw(false, () => {
// Canvas绘制完成后生成图片
uni.canvasToTempFilePath({
canvasId: "heatMapCanvas",
width: width,
height: height,
destWidth: width * 2, // 提高图片质量
destHeight: height * 2,
success: (res) => {
console.log("热力图图片生成成功:", res.tempFilePath);
resolve(res.tempFilePath);
},
fail: (error) => {
console.error("热力图图片生成失败:", error);
reject(error);
},
});
});
});
}
/**
* 把强度 0-1 映射成红-黄-绿渐变,返回 rgba 字符串
* @param {Number} v 0-1
* @param {Number} a 透明度 0-1
*/
function heatColor(v, a) {
// v 从 0→1重新映射极低值绿色低值黄色中到高值红色
let red, green;
if (v < 0.2) {
// 极低值:纯绿色
red = 0;
green = 200; // 柔和的绿
} else if (v < 0.4) {
// 低值:绿色到黄色
const t = (v - 0.2) / 0.2;
red = Math.round(255 * t);
green = 255;
} else if (v < 0.6) {
// 中低值:黄色到橙色
const t = (v - 0.4) / 0.2;
red = 255;
green = Math.round(255 * (1 - t * 0.5));
} else {
// 中到高值:橙色到红色
const t = (v - 0.6) / 0.4;
red = 255;
green = Math.round(128 * (1 - t));
}
const blue = 0;
return `rgba(${red}, ${green}, ${blue}, ${a})`;
}

View File

@@ -16,7 +16,8 @@ import {
getPointBookStatisticsAPI,
} from "@/apis";
import { getElementRect, drawHeatMap } from "@/util";
import { getElementRect } from "@/util";
import { generateHeatmapImage } from "@/heatmap";
import useStore from "@/store";
import { storeToRefs } from "pinia";
@@ -38,6 +39,7 @@ const data = ref({
const list = ref([]);
const bowTargetSrc = ref("");
const heatMapImageSrc = ref(""); // 存储热力图图片地址
const canvasVisible = ref(false); // 控制canvas显示状态
const toListPage = () => {
uni.navigateTo({
@@ -59,10 +61,54 @@ const startScoring = () => {
}
};
// 生成热力图测试数据
const generateHeatMapData = (width, height, amount = 100) => {
const data = [];
const centerX = 0.5; // 中心点X坐标
const centerY = 0.5; // 中心点Y坐标
// 生成500条记录
for (let i = 0; i < amount; i++) {
let x, y, count;
// 30%的数据集中在中心区域(高斯分布)
if (Math.random() < 0.3) {
// 使用正态分布生成中心区域的数据
const angle = Math.random() * 2 * Math.PI;
const radius = Math.sqrt(-2 * Math.log(Math.random())) * 0.15; // 标准差0.15
x = centerX + radius * Math.cos(angle);
y = centerY + radius * Math.sin(angle);
count = Math.floor(Math.random() * 20);
} else {
x = Math.random() * 0.8 + 0.1; // 0.1-0.9范围
y = Math.random() * 0.8 + 0.1;
count = Math.floor(Math.random() * 20);
}
// 确保坐标在0-1范围内
x = Math.max(0.05, Math.min(0.95, x));
y = Math.max(0.05, Math.min(0.95, y));
data.push({
x: parseFloat(x.toFixed(3)),
y: parseFloat(y.toFixed(3)),
ring: Math.floor(Math.random() * 5) + 6, // 6-10环
count: count,
});
}
return data;
};
const loadData = async () => {
const result = await getPointBookListAPI(1);
list.value = result.slice(0, 3);
const result2 = await getPointBookStatisticsAPI();
const rect = await getElementRect(".heat-map");
// const testWeekArrows = generateHeatMapData(rect.width, rect.height);
// result2.weekArrows = testWeekArrows;
// console.log(result2.weekArrows)
data.value = result2;
let hot = 0;
if (result2.checkInCount > -3 && result2.checkInCount < 3) hot = 1;
@@ -70,14 +116,13 @@ const loadData = async () => {
else if (result2.checkInCount >= 5) hot = 3;
else if (result2.checkInCount === 7) hot = 4;
uni.$emit("update-hot", hot);
const rect = await getElementRect(".heat-map");
// 延迟绘制热力图确保Canvas准备就绪
setTimeout(async () => {
try {
const imagePath = await drawHeatMap(
const imagePath = await generateHeatmapImage(
"heatMapCanvas",
rect.width,
rect.height,
result2.weekArrows
result2.weekArrows.filter((item) => item.x && item.y)
);
heatMapImageSrc.value = imagePath; // 存储生成的图片地址
console.log("热力图图片地址:", imagePath);
@@ -247,7 +292,17 @@ onShareTimeline(() => {
mode="widthFix"
/>
<image v-if="heatMapImageSrc" :src="heatMapImageSrc" mode="widthFix" />
<canvas canvas-id="heatMapCanvas" style="width: 100%; height: 100%" />
<canvas
canvas-id="heatMapCanvas"
style="
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 2;
"
/>
</view>
<view class="reward" v-if="data.totalArrow">
<button hover-class="none" @click="showTip = true">
@@ -392,8 +447,8 @@ onShareTimeline(() => {
.heat-map > canvas {
position: absolute;
top: 0;
left: -1000px;
top: -1000px;
left: 0px;
width: 100%;
height: 100%;
z-index: 2;

View File

@@ -445,75 +445,3 @@ export const calcRing = (bowtargetId, x, y, diameter) => {
}
return 0;
};
// 绘制热力图并生成图片
export const drawHeatMap = (width, height, arrows) => {
return new Promise((resolve, reject) => {
try {
const ctx = uni.createCanvasContext("heatMapCanvas");
// ctx.setFillStyle("rgba(255, 255, 255, 0.4)");
// ctx.fillRect(0, 0, width, height);
let minCount = 0;
let maxCount = 0;
// 计算最大和最小频次
arrows.forEach((point) => {
if (point.count > maxCount) {
maxCount = point.count;
}
if (point.count < minCount) {
minCount = point.count;
}
});
// 绘制热力点
arrows.forEach((point) => {
if (point.x === 0 && point.y === 0) return;
// 数量越多,半径越小(反比关系)
const radius = 20;
// 根据频次设置同一种颜色的5个深浅度
let color = "rgba(71, 254, 102, 0.2)";
// if (point.count >= maxCount * 0.5) {
// color = "rgba(255, 232, 143, 0.7)"; // 最深 - 频次5次及以上
// } else if (point.count >= maxCount * 0.4) {
// color = "rgba(255, 232, 143, 0.5)"; // 较深 - 频次4次
// } else if (point.count >= maxCount * 0.3) {
// color = "rgba(255, 232, 143, 0.3)"; // 中等 - 频次3次
// } else if (point.count >= maxCount * 0.2) {
// color = "rgba(255, 232, 143, 0.2)"; // 较浅 - 频次2次
// } else {
// color = "rgba(255, 232, 143, 0.1)"; // 最浅 - 频次1次
// }
// 绘制圆形热力点
ctx.setFillStyle(color);
ctx.beginPath();
ctx.arc(point.x * width, point.y * height, radius, 0, 2 * Math.PI);
ctx.fill();
});
ctx.draw(false, () => {
// Canvas绘制完成后生成图片
uni.canvasToTempFilePath({
canvasId: "heatMapCanvas",
width: width,
height: height,
destWidth: width * 2, // 提高图片质量
destHeight: height * 2,
success: (res) => {
console.log("热力图图片生成成功:", res.tempFilePath);
resolve(res.tempFilePath);
},
fail: (error) => {
console.error("热力图图片生成失败:", error);
reject(error);
},
});
});
} catch (error) {
console.error("绘制热力图失败:", error);
reject(error);
}
});
};