From 22429bda5243a409e49eae09e2dcea5c93bfae8a Mon Sep 17 00:00:00 2001 From: kron Date: Fri, 3 Oct 2025 11:16:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=97=A0=E7=94=A8=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/kde-heatmap.js | 242 ++++++++++++++++++++------------------------- 1 file changed, 108 insertions(+), 134 deletions(-) diff --git a/src/kde-heatmap.js b/src/kde-heatmap.js index 14e3dbc..a50c9d6 100644 --- a/src/kde-heatmap.js +++ b/src/kde-heatmap.js @@ -30,28 +30,31 @@ function kernelDensityEstimator(kernel, range, samples) { const gridSize = (range[1] - range[0]) / samples; const densityData = []; const bandwidth = 0.8; // 从核函数中提取带宽 - + // 预计算核函数值缓存(减少重复计算) const kernelCache = new Map(); - const maxDistance = Math.ceil(bandwidth * 2 / gridSize); // 最大影响范围 - + 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])); + kernelCache.set( + `${dx},${dy}`, + kernel([dx * gridSize, dy * gridSize]) + ); } } } // 使用稀疏网格计算(只计算有数据点影响的区域) const affectedGridPoints = new Set(); - + // 第一步:找出所有受影响的网格点 - data.forEach(point => { + 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++) { @@ -65,26 +68,26 @@ function kernelDensityEstimator(kernel, range, samples) { }); // 第二步:只计算受影响的网格点 - affectedGridPoints.forEach(gridKey => { - const [gridX, gridY] = gridKey.split(',').map(Number); + 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 => { + 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]); } @@ -102,32 +105,6 @@ function kernelDensityEstimator(kernel, range, samples) { }; } -/** - * 生成随机射箭数据点 - * @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 @@ -144,7 +121,9 @@ function getHeatColor(density) { // 低密度:浅绿色 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})`; + return `rgba(${Math.round(50 * intensity)}, ${green}, ${blue}, ${ + alpha * 0.7 + })`; } else { // 高密度:深绿色 const red = Math.round(50 * (intensity - 0.5) * 2); @@ -169,19 +148,11 @@ const heatmapCache = new Map(); 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, + showPoints = false, pointColor = "rgba(255, 255, 255, 0.9)", } = options; @@ -200,10 +171,10 @@ export function drawKDEHeatmap(canvasId, width, height, points, options = {}) { // 清空画布 ctx.clearRect(0, 0, width, height); - + // iOS兼容性:设置全局合成操作,让颜色叠加更自然 try { - ctx.globalCompositeOperation = 'screen'; + ctx.globalCompositeOperation = "screen"; } catch (error) { console.warn("设置全局合成操作失败,使用默认设置:", error); } @@ -219,28 +190,34 @@ export function drawKDEHeatmap(canvasId, width, height, points, options = {}) { 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 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(); + 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') { + if (typeof requestAnimationFrame === "function") { try { requestAnimationFrame(processChunk); } catch (e) { @@ -253,19 +230,22 @@ export function drawKDEHeatmap(canvasId, width, height, points, options = {}) { return; } } - + ctx.restore(); // 恢复状态 index = endIndex; frameCount++; - + if (index < data.length) { // 动态调整延迟:如果处理时间超过16ms(一帧),使用更大延迟 - const currentTime = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); + 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') { + if (typeof requestAnimationFrame === "function") { try { requestAnimationFrame(processChunk); } catch (e) { @@ -279,7 +259,7 @@ export function drawKDEHeatmap(canvasId, width, height, points, options = {}) { resolve(); } }; - + processChunk(); }); }; @@ -292,30 +272,40 @@ export function drawKDEHeatmap(canvasId, width, height, points, options = {}) { const canvasX = normalizedX * width; const canvasY = normalizedY * height; const color = getHeatColor(density); - + // iOS兼容性:确保数值有效 - if (isNaN(canvasX) || isNaN(canvasY) || !isFinite(canvasX) || !isFinite(canvasY)) { + 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 + 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))}`; - + const cacheKey = `${bandwidth}-${gridSize}-${range.join(",")}-${ + points.length + }-${JSON.stringify(points.slice(0, 10))}`; + // 检查缓存 if (heatmapCache.has(cacheKey)) { - console.log('使用缓存的热力图数据'); + console.log("使用缓存的热力图数据"); const cachedDensityData = heatmapCache.get(cacheKey); - + // 使用分片处理绘制缓存数据 - await processInChunks(cachedDensityData, 200); // 每批处理200个点,减少单次处理量 - + await processInChunks(cachedDensityData, 200); // 每批处理200个点,减少单次处理量 + // 绘制原始数据点 - iOS兼容性优化 if (showPoints) { ctx.setFillStyle(pointColor); @@ -323,34 +313,43 @@ export function drawKDEHeatmap(canvasId, width, height, points, options = {}) { 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)) { + 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)); - }); + + ctx.draw( + false, + () => { + console.log("KDE热力图绘制完成(缓存)"); + resolve(); + }, + (error) => { + console.error("KDE热力图绘制失败(缓存):", error); + reject(new Error("Canvas绘制失败(缓存): " + error)); + } + ); return; } @@ -358,7 +357,7 @@ export function drawKDEHeatmap(canvasId, width, height, points, options = {}) { const kernel = kernelEpanechnikov(bandwidth); const kde = kernelDensityEstimator(kernel, range, gridSize); const densityData = kde(points); - + // 缓存结果(限制缓存大小) if (heatmapCache.size > 10) { const firstKey = heatmapCache.keys().next().value; @@ -380,21 +379,26 @@ export function drawKDEHeatmap(canvasId, width, height, points, options = {}) { 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)) { + 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(); // 一次性填充所有圆点 @@ -402,13 +406,17 @@ export function drawKDEHeatmap(canvasId, width, height, points, options = {}) { } // 执行绘制 - iOS兼容性优化 - ctx.draw(false, () => { - console.log("KDE热力图绘制完成"); - resolve(); - }, (error) => { - console.error("KDE热力图绘制失败:", error); - reject(new Error("Canvas绘制失败: " + error)); - }); + 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); @@ -459,39 +467,5 @@ export function generateKDEHeatmapImage( */ export function clearHeatmapCache() { heatmapCache.clear(); - console.log('热力图缓存已清除'); + 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; -};