/** * 基于小程序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 = {}) { const { bandwidth = 0.8, gridSize = 100, range = [-4, 4], showPoints = true, pointColor = "rgba(255, 255, 255, 0.9)", } = options; // 微信小程序使用 Canvas 2D return new Promise((resolve, reject) => { try { wx.createSelectorQuery() .select(`#${canvasId}`) .fields({ node: true, size: true }) .exec((res) => { try { const { node: canvas, width: w, height: h } = res[0] || {}; if (!canvas) return resolve(); // 设置画布尺寸 const cw = width || w || 300; const ch = height || h || 300; canvas.width = cw; canvas.height = ch; const ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, cw, ch); if (!points || points.length === 0) return resolve(); // 计算核密度估计 const kernel = kernelEpanechnikov(bandwidth); const kde = kernelDensityEstimator(kernel, range, gridSize); const densityData = kde(points); // 计算网格大小 const cellWidth = cw / gridSize; const cellHeight = ch / gridSize; const xRange = range[1] - range[0]; const yRange = range[1] - range[0]; // 绘制热力图网格 densityData.forEach(([x, y, density]) => { const normalizedX = (x - range[0]) / xRange; const normalizedY = (y - range[0]) / yRange; const canvasX = normalizedX * cw; const canvasY = normalizedY * ch; const color = getHeatColor(density); ctx.fillStyle = color; ctx.beginPath(); ctx.arc( canvasX, canvasY, Math.min(cellWidth, cellHeight) * 0.6, 0, 2 * Math.PI ); ctx.fill(); }); // 绘制原始数据点 if (showPoints) { ctx.fillStyle = pointColor; points.forEach(([x, y]) => { const normalizedX = (x - range[0]) / xRange; const normalizedY = (y - range[0]) / yRange; const canvasX = normalizedX * cw; const canvasY = normalizedY * ch; ctx.beginPath(); ctx.arc(canvasX, canvasY, 2.5, 0, 2 * Math.PI); ctx.fill(); }); } resolve(); } catch (err) { reject(err); } }); } catch (error) { reject(error); } }); } /** * 生成热力图图片(类似原有的generateHeatmapImage函数) * 但使用核密度估计算法 */ export function generateKDEHeatmapImage( canvasId, width, height, points, options = {} ) { // Canvas 2D 导出(传入 canvas 对象) return new Promise((resolve, reject) => { drawKDEHeatmap(canvasId, width, height, points, options) .then(() => { try { wx.createSelectorQuery() .select(`#${canvasId}`) .fields({ node: true, size: true }) .exec((res) => { const { node: canvas, width: w, height: h } = res[0] || {}; if (!canvas) return reject(new Error("canvas 为空")); const cw = width || w || 300; const ch = height || h || 300; uni.canvasToTempFilePath({ canvas, width: cw, height: ch, destWidth: cw * 3, destHeight: ch * 3, success: (r) => resolve(r.tempFilePath), fail: reject, }); }); } catch (e) { reject(e); } }) .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; };