/** * 基于小程序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 = []; const bandwidth = 0.8; // 从核函数中提取带宽 // 预计算核函数值缓存(减少重复计算) const kernelCache = new Map(); const maxDistance = Math.ceil(bandwidth * 2 / gridSize); // 最大影响范围 for (let dx = -maxDistance; dx <= maxDistance; dx++) { for (let dy = -maxDistance; dy <= maxDistance; dy++) { const distance = Math.sqrt(dx * dx + dy * dy) * gridSize; if (distance <= bandwidth * 2) { kernelCache.set(`${dx},${dy}`, kernel([dx * gridSize, dy * gridSize])); } } } // 使用稀疏网格计算(只计算有数据点影响的区域) const affectedGridPoints = new Set(); // 第一步:找出所有受影响的网格点 data.forEach(point => { const centerX = Math.round((point[0] - range[0]) / gridSize); const centerY = Math.round((point[1] - range[0]) / gridSize); // 只考虑带宽范围内的网格点 for (let dx = -maxDistance; dx <= maxDistance; dx++) { for (let dy = -maxDistance; dy <= maxDistance; dy++) { const gridX = centerX + dx; const gridY = centerY + dy; if (gridX >= 0 && gridX < samples && gridY >= 0 && gridY < samples) { affectedGridPoints.add(`${gridX},${gridY}`); } } } }); // 第二步:只计算受影响的网格点 affectedGridPoints.forEach(gridKey => { const [gridX, gridY] = gridKey.split(',').map(Number); const x = range[0] + gridX * gridSize; const y = range[0] + gridY * gridSize; let sum = 0; let validPoints = 0; // 只考虑附近的点(空间分割优化) data.forEach(point => { const dx = (x - point[0]) / gridSize; const dy = (y - point[1]) / gridSize; const cacheKey = `${Math.round(dx)},${Math.round(dy)}`; if (kernelCache.has(cacheKey)) { sum += kernelCache.get(cacheKey); validPoints++; } }); if (validPoints > 0) { densityData.push([x, y, sum / data.length]); } }); // 归一化 if (densityData.length > 0) { 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.8})`; } } // 添加缓存机制 const heatmapCache = new Map(); /** * 基于小程序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(async (resolve, reject) => { try { // iOS兼容性:限制canvas尺寸,避免内存问题 const maxCanvasSize = 1500; // iOS设备最大建议尺寸 if (width > maxCanvasSize || height > maxCanvasSize) { const scale = Math.min(maxCanvasSize / width, maxCanvasSize / height); width = Math.floor(width * scale); height = Math.floor(height * scale); console.log(`iOS兼容性:限制canvas尺寸为 ${width}x${height}`); } const { bandwidth = 0.8, gridSize = 100, range = [-4, 4], showPoints = true, pointColor = "rgba(255, 255, 255, 0.9)", } = options; // 创建绘图上下文 - iOS兼容性检查 let ctx; try { ctx = uni.createCanvasContext(canvasId); if (!ctx) { throw new Error("无法创建canvas上下文"); } } catch (error) { console.error("创建canvas上下文失败:", error); reject(new Error("iOS兼容性:Canvas上下文创建失败")); return; } // 清空画布 ctx.clearRect(0, 0, width, height); // iOS兼容性:设置全局合成操作,让颜色叠加更自然 try { ctx.globalCompositeOperation = 'screen'; } catch (error) { console.warn("设置全局合成操作失败,使用默认设置:", error); } // 如果没有数据,直接绘制 if (!points || points.length === 0) { ctx.draw(false, () => resolve()); return; } // 使用分片处理,避免长时间阻塞主线程 const processInChunks = (data, chunkSize = 1000) => { return new Promise((resolve) => { let index = 0; let frameCount = 0; const processChunk = () => { // iOS兼容性:使用Date.now()作为performance.now的回退 const startTime = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); const endIndex = Math.min(index + chunkSize, data.length); // 批量处理多个点,减少函数调用开销 ctx.save(); // 保存当前状态 for (let i = index; i < endIndex; i++) { const point = data[i]; // 处理单个点的绘制逻辑 processPoint(point); // 每处理50个点检查一次时间,避免超时 const currentTime = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); if (i % 50 === 0 && currentTime - startTime > 8) { // 如果处理时间超过8ms,保存状态并中断 index = i + 1; ctx.restore(); // iOS兼容性:更安全的requestAnimationFrame检测 if (typeof requestAnimationFrame === 'function') { try { requestAnimationFrame(processChunk); } catch (e) { // 如果requestAnimationFrame失败,使用setTimeout setTimeout(processChunk, 2); } } else { setTimeout(processChunk, 2); // 小延迟后继续 } return; } } ctx.restore(); // 恢复状态 index = endIndex; frameCount++; if (index < data.length) { // 动态调整延迟:如果处理时间超过16ms(一帧),使用更大延迟 const currentTime = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); const processingTime = currentTime - startTime; const delay = processingTime > 16 ? 8 : 1; // 根据处理时间动态调整 // iOS兼容性:更安全的requestAnimationFrame检测 if (typeof requestAnimationFrame === 'function') { try { requestAnimationFrame(processChunk); } catch (e) { // 如果requestAnimationFrame失败,使用setTimeout setTimeout(processChunk, delay); } } else { setTimeout(processChunk, delay); } } else { resolve(); } }; processChunk(); }); }; // 处理单个点的函数 - iOS兼容性优化 const processPoint = (point) => { const [x, y, density] = point; const normalizedX = (x - range[0]) / (range[1] - range[0]); const normalizedY = (y - range[0]) / (range[1] - range[0]); const canvasX = normalizedX * width; const canvasY = normalizedY * height; const color = getHeatColor(density); // iOS兼容性:确保数值有效 if (isNaN(canvasX) || isNaN(canvasY) || !isFinite(canvasX) || !isFinite(canvasY)) { return; } ctx.setFillStyle(color); ctx.beginPath(); const radius = Math.max(1, Math.min(width / gridSize, height / gridSize) * 0.6); // 确保半径至少为1 ctx.arc(canvasX, canvasY, radius, 0, 2 * Math.PI); ctx.fill(); }; // 生成缓存key(基于参数和数据点的哈希) const cacheKey = `${bandwidth}-${gridSize}-${range.join(',')}-${points.length}-${JSON.stringify(points.slice(0, 10))}`; // 检查缓存 if (heatmapCache.has(cacheKey)) { console.log('使用缓存的热力图数据'); const cachedDensityData = heatmapCache.get(cacheKey); // 使用分片处理绘制缓存数据 await processInChunks(cachedDensityData, 200); // 每批处理200个点,减少单次处理量 // 绘制原始数据点 - iOS兼容性优化 if (showPoints) { ctx.setFillStyle(pointColor); ctx.beginPath(); // 开始批量路径 const xRange = range[1] - range[0]; const yRange = range[1] - range[0]; let validPoints = 0; 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; // iOS兼容性:确保坐标有效 if (!isNaN(canvasX) && !isNaN(canvasY) && isFinite(canvasX) && isFinite(canvasY)) { ctx.arc(canvasX, canvasY, 2.5, 0, 2 * Math.PI); validPoints++; } }); // 只有在有有效点的情况下才执行填充 if (validPoints > 0) { ctx.fill(); // 一次性填充所有圆点 } } ctx.draw(false, () => { console.log("KDE热力图绘制完成(缓存)"); resolve(); }, (error) => { console.error("KDE热力图绘制失败(缓存):", error); reject(new Error("Canvas绘制失败(缓存): " + error)); }); return; } // 计算核密度估计 const kernel = kernelEpanechnikov(bandwidth); const kde = kernelDensityEstimator(kernel, range, gridSize); const densityData = kde(points); // 缓存结果(限制缓存大小) if (heatmapCache.size > 10) { const firstKey = heatmapCache.keys().next().value; heatmapCache.delete(firstKey); } heatmapCache.set(cacheKey, densityData); // 计算网格大小 const cellWidth = width / gridSize; const cellHeight = height / gridSize; const xRange = range[1] - range[0]; const yRange = range[1] - range[0]; // 使用分片处理绘制热力图网格 await processInChunks(densityData, 200); // 每批处理200个点,减少单次处理量 // 绘制原始数据点 - iOS兼容性优化 if (showPoints) { ctx.setFillStyle(pointColor); ctx.beginPath(); // 开始批量路径 let validPoints = 0; 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; // iOS兼容性:确保坐标有效 if (!isNaN(canvasX) && !isNaN(canvasY) && isFinite(canvasX) && isFinite(canvasY)) { ctx.arc(canvasX, canvasY, 2.5, 0, 2 * Math.PI); validPoints++; } }); // 只有在有有效点的情况下才执行填充 if (validPoints > 0) { ctx.fill(); // 一次性填充所有圆点 } } // 执行绘制 - iOS兼容性优化 ctx.draw(false, () => { console.log("KDE热力图绘制完成"); resolve(); }, (error) => { console.error("KDE热力图绘制失败:", error); reject(new Error("Canvas绘制失败: " + error)); }); } 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(() => { // 生成图片 - iOS兼容性优化 uni.canvasToTempFilePath({ canvasId: canvasId, width: width, height: height, destWidth: width * 2, // iOS兼容性:降低分辨率避免内存问题 destHeight: height * 2, fileType: "png", // iOS兼容性:明确指定png格式 quality: 1, // iOS兼容性:最高质量 success: (res) => { console.log("KDE热力图图片生成成功:", res.tempFilePath); resolve(res.tempFilePath); }, fail: (error) => { console.error("KDE热力图图片生成失败:", error); reject(error); }, }); }) .catch(reject); }); } /** * 清除热力图缓存 * 在数据或参数需要强制更新时调用 */ export function clearHeatmapCache() { heatmapCache.clear(); console.log('热力图缓存已清除'); } 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; };