diff --git a/src/App.vue b/src/App.vue index 0d983d5..309f5e7 100644 --- a/src/App.vue +++ b/src/App.vue @@ -64,6 +64,12 @@ button { box-sizing: border-box; } +view::-webkit-scrollbar { + width: 0; + height: 0; + color: transparent; +} + button::after { border: none; } diff --git a/src/kde-heatmap.js b/src/kde-heatmap.js new file mode 100644 index 0000000..9358dda --- /dev/null +++ b/src/kde-heatmap.js @@ -0,0 +1,270 @@ +/** + * 基于小程序Canvas API的核密度估计热力图 + * 实现类似test.html中的效果,但适配uni-app小程序环境 + */ + +/** + * Epanechnikov核函数 + * @param {Number} bandwidth 带宽参数 + * @returns {Function} 核函数 + */ +function kernelEpanechnikov(bandwidth) { + return function (v) { + const r = Math.sqrt(v[0] * v[0] + v[1] * v[1]); + return r <= bandwidth + ? (3 / (Math.PI * bandwidth * bandwidth)) * + (1 - (r * r) / (bandwidth * bandwidth)) + : 0; + }; +} + +/** + * 核密度估计器 + * @param {Function} kernel 核函数 + * @param {Array} range 范围[xmin, xmax] + * @param {Number} samples 采样点数 + * @returns {Function} 密度估计函数 + */ +function kernelDensityEstimator(kernel, range, samples) { + return function (data) { + const gridSize = (range[1] - range[0]) / samples; + const densityData = []; + + for (let x = range[0]; x <= range[1]; x += gridSize) { + for (let y = range[0]; y <= range[1]; y += gridSize) { + let sum = 0; + for (const point of data) { + sum += kernel([x - point[0], y - point[1]]); + } + densityData.push([x, y, sum / data.length]); + } + } + + // 归一化 + const maxDensity = Math.max(...densityData.map((d) => d[2])); + densityData.forEach((d) => { + if (maxDensity > 0) d[2] /= maxDensity; + }); + + return densityData; + }; +} + +/** + * 生成随机射箭数据点 + * @param {Number} centerCount 中心点数量 + * @param {Number} pointsPerCenter 每个中心点的箭数 + * @returns {Array} 箭矢坐标数组 + */ +export function generateArcheryPoints(centerCount = 2, pointsPerCenter = 100) { + const points = []; + const range = 8; // 坐标范围 -4 到 4 + const spread = 3; // 分散度 + + for (let i = 0; i < centerCount; i++) { + const centerX = Math.random() * range - range / 2; + const centerY = Math.random() * range - range / 2; + + for (let j = 0; j < pointsPerCenter; j++) { + points.push([ + centerX + (Math.random() - 0.5) * spread, + centerY + (Math.random() - 0.5) * spread, + ]); + } + } + + return points; +} + +/** + * 颜色映射函数 - 将密度值映射到颜色 + * @param {Number} density 密度值 0-1 + * @returns {String} RGBA颜色字符串 + */ +function getHeatColor(density) { + // 绿色系热力图:从浅绿到深绿 + if (density < 0.1) return "rgba(0, 255, 0, 0)"; + + const alpha = Math.min(density * 1.2, 1); // 增强透明度 + const intensity = density; + + if (intensity < 0.5) { + // 低密度:浅绿色 + const green = Math.round(200 + 55 * intensity); + const blue = Math.round(50 + 100 * intensity); + return `rgba(${Math.round(50 * intensity)}, ${green}, ${blue}, ${alpha * 0.7})`; + } else { + // 高密度:深绿色 + const red = Math.round(50 * (intensity - 0.5) * 2); + const green = Math.round(180 + 75 * (1 - intensity)); + const blue = Math.round(30 * (1 - intensity)); + return `rgba(${red}, ${green}, ${blue}, ${alpha * 0.7})`; + } +} + +/** + * 基于小程序Canvas API绘制核密度估计热力图 + * @param {String} canvasId 画布ID + * @param {Number} width 画布宽度 + * @param {Number} height 画布高度 + * @param {Array} points 箭矢坐标数组 [[x, y], ...] + * @param {Object} options 可选参数 + * @returns {Promise} 绘制完成的Promise + */ +export function drawKDEHeatmap(canvasId, width, height, points, options = {}) { + return new Promise((resolve, reject) => { + try { + const { + bandwidth = 0.8, + gridSize = 100, + range = [-4, 4], + showPoints = true, + pointColor = "rgba(255, 255, 255, 0.9)", + } = options; + + // 创建绘图上下文 + const ctx = uni.createCanvasContext(canvasId); + + // 清空画布 + ctx.clearRect(0, 0, width, height); + + // 如果没有数据,直接绘制 + if (!points || points.length === 0) { + ctx.draw(false, () => resolve()); + return; + } + + // 计算核密度估计 + const kernel = kernelEpanechnikov(bandwidth); + const kde = kernelDensityEstimator(kernel, range, gridSize); + const densityData = kde(points); + + // 计算网格大小 + const cellWidth = width / gridSize; + const cellHeight = height / gridSize; + const xRange = range[1] - range[0]; + const yRange = range[1] - range[0]; + + // 绘制热力图网格 + densityData.forEach((point) => { + const [x, y, density] = point; + + // 将逻辑坐标转换为画布坐标 + const normalizedX = (x - range[0]) / xRange; + const normalizedY = (y - range[0]) / yRange; + const canvasX = normalizedX * width; + const canvasY = normalizedY * height; + + // 获取颜色 + const color = getHeatColor(density); + + // 绘制单元格(使用圆形绘制,边缘更平滑) + ctx.setFillStyle(color); + ctx.beginPath(); + ctx.arc( + canvasX, + canvasY, + Math.min(cellWidth, cellHeight) * 0.6, + 0, + 2 * Math.PI + ); + ctx.fill(); + }); + + // 绘制原始数据点 + if (showPoints) { + ctx.setFillStyle(pointColor); + points.forEach((point) => { + const [x, y] = point; + const normalizedX = (x - range[0]) / xRange; + const normalizedY = (y - range[0]) / yRange; + const canvasX = normalizedX * width; + const canvasY = normalizedY * height; + + // 绘制小圆点 + ctx.beginPath(); + ctx.arc(canvasX, canvasY, 2.5, 0, 2 * Math.PI); + ctx.fill(); + }); + } + + // 执行绘制 + ctx.draw(false, () => { + console.log("KDE热力图绘制完成"); + resolve(); + }); + } catch (error) { + console.error("KDE热力图绘制失败:", error); + reject(error); + } + }); +} + +/** + * 生成热力图图片(类似原有的generateHeatmapImage函数) + * 但使用核密度估计算法 + */ +export function generateKDEHeatmapImage( + canvasId, + width, + height, + points, + options = {} +) { + return new Promise((resolve, reject) => { + drawKDEHeatmap(canvasId, width, height, points, options) + .then(() => { + // 生成图片 + uni.canvasToTempFilePath({ + canvasId: canvasId, + width: width, + height: height, + destWidth: width * 3, // 提高输出分辨率,让图像更细腻 + destHeight: height * 3, + success: (res) => { + console.log("KDE热力图图片生成成功:", res.tempFilePath); + resolve(res.tempFilePath); + }, + fail: (error) => { + console.error("KDE热力图图片生成失败:", error); + reject(error); + }, + }); + }) + .catch(reject); + }); +} + +export const generateHeatMapData = (width, height, amount = 100) => { + const data = []; + const centerX = 0.5; // 中心点X坐标 + const centerY = 0.5; // 中心点Y坐标 + + for (let i = 0; i < amount; i++) { + let x, y; + + // 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); + } else { + x = Math.random() * 0.8 + 0.1; // 0.1-0.9范围 + y = Math.random() * 0.8 + 0.1; + } + + // 确保坐标在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环 + }); + } + + return data; +}; diff --git a/src/pages/point-book.vue b/src/pages/point-book.vue index 0e042d1..e83b825 100644 --- a/src/pages/point-book.vue +++ b/src/pages/point-book.vue @@ -17,7 +17,8 @@ import { } from "@/apis"; import { getElementRect } from "@/util"; -import { generateHeatmapImage } from "@/heatmap"; + +import { generateKDEHeatmapImage } from "@/kde-heatmap"; import useStore from "@/store"; import { storeToRefs } from "pinia"; @@ -61,75 +62,39 @@ 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(); + data.value = result2; 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; else if (result2.checkInCount >= 3) hot = 2; else if (result2.checkInCount >= 5) hot = 3; else if (result2.checkInCount === 7) hot = 4; uni.$emit("update-hot", hot); - setTimeout(async () => { - try { - const imagePath = await generateHeatmapImage( - "heatMapCanvas", - rect.width, - rect.height, - result2.weekArrows.filter((item) => item.x && item.y) - ); - heatMapImageSrc.value = imagePath; // 存储生成的图片地址 - console.log("热力图图片地址:", imagePath); - } catch (error) { - console.error("生成热力图图片失败:", error); - } - }, 500); + try { + const imagePath = await generateKDEHeatmapImage( + "heatMapCanvas", + rect.width, + rect.height, + result2.weekArrows + .filter((item) => item.x && item.y) + .map((item) => [item.x, item.y]), + { + range: [0, 1], // 适配0-1坐标范围 + gridSize: 150, // 更高的网格密度,减少锯齿 + bandwidth: 0.15, // 稍小的带宽,让热力图更细腻 + showPoints: true, // 显示白色原始数据点 + } + ); + heatMapImageSrc.value = imagePath; // 存储生成的图片地址 + console.log("热力图图片地址:", imagePath); + } catch (error) { + console.error("生成热力图图片失败:", error); + } }; watch(