From 9c6824b82f0dee40bd686128a1097a7d3b2b18bc Mon Sep 17 00:00:00 2001 From: kron Date: Sun, 28 Sep 2025 18:28:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=B8=80=E7=89=88=E7=83=AD?= =?UTF-8?q?=E5=8A=9B=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/heatmap.js | 105 +++++++++++++++++++++++++++++++++++++++ src/pages/point-book.vue | 71 +++++++++++++++++++++++--- src/util.js | 72 --------------------------- 3 files changed, 168 insertions(+), 80 deletions(-) create mode 100644 src/heatmap.js diff --git a/src/heatmap.js b/src/heatmap.js new file mode 100644 index 0000000..47bd0f0 --- /dev/null +++ b/src/heatmap.js @@ -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})`; +} diff --git a/src/pages/point-book.vue b/src/pages/point-book.vue index 745c875..0e042d1 100644 --- a/src/pages/point-book.vue +++ b/src/pages/point-book.vue @@ -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" /> - +