diff --git a/src/kde-heatmap.js b/src/kde-heatmap.js index 9358dda..ef21d76 100644 --- a/src/kde-heatmap.js +++ b/src/kde-heatmap.js @@ -19,7 +19,7 @@ function kernelEpanechnikov(bandwidth) { } /** - * 核密度估计器 + * 核密度估计器 - 优化版本 * @param {Function} kernel 核函数 * @param {Array} range 范围[xmin, xmax] * @param {Number} samples 采样点数 @@ -29,23 +29,75 @@ 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]]); + 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])); } - 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; + // 使用稀疏网格计算(只计算有数据点影响的区域) + 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; }; } @@ -102,8 +154,11 @@ function getHeatColor(density) { } } +// 添加缓存机制 +const heatmapCache = new Map(); + /** - * 基于小程序Canvas API绘制核密度估计热力图 + * 基于小程序Canvas API绘制核密度估计热力图 - 带缓存优化 * @param {String} canvasId 画布ID * @param {Number} width 画布宽度 * @param {Number} height 画布高度 @@ -134,10 +189,68 @@ export function drawKDEHeatmap(canvasId, width, height, points, options = {}) { return; } + // 生成缓存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); + + // 直接使用缓存数据绘制 + const cellWidth = width / gridSize; + const cellHeight = height / gridSize; + const xRange = range[1] - range[0]; + const yRange = range[1] - range[0]; + + cachedDensityData.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(); + }); + 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; @@ -145,47 +258,50 @@ export function drawKDEHeatmap(canvasId, width, height, points, options = {}) { const xRange = range[1] - range[0]; const yRange = range[1] - range[0]; - // 绘制热力图网格 + // 绘制热力图网格 - 批量绘制优化 + const colorGroups = new Map(); + + // 按颜色分组,减少setFillStyle调用 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); - - // 绘制单元格(使用圆形绘制,边缘更平滑) + + if (!colorGroups.has(color)) { + colorGroups.set(color, []); + } + colorGroups.get(color).push(point); + }); + + // 批量绘制相同颜色的点 + colorGroups.forEach((points, color) => { ctx.setFillStyle(color); - ctx.beginPath(); - ctx.arc( - canvasX, - canvasY, - Math.min(cellWidth, cellHeight) * 0.6, - 0, - 2 * Math.PI - ); - ctx.fill(); + points.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; + + ctx.beginPath(); + ctx.arc(canvasX, canvasY, Math.min(cellWidth, cellHeight) * 0.6, 0, 2 * Math.PI); + ctx.fill(); + }); }); - // 绘制原始数据点 + // 绘制原始数据点 - 批量绘制优化 if (showPoints) { ctx.setFillStyle(pointColor); + ctx.beginPath(); // 开始批量路径 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.fill(); // 一次性填充所有圆点 } // 执行绘制 @@ -235,6 +351,15 @@ export function generateKDEHeatmapImage( }); } +/** + * 清除热力图缓存 + * 在数据或参数需要强制更新时调用 + */ +export function clearHeatmapCache() { + heatmapCache.clear(); + console.log('热力图缓存已清除'); +} + export const generateHeatMapData = (width, height, amount = 100) => { const data = []; const centerX = 0.5; // 中心点X坐标