diff --git a/src/kde-heatmap.js b/src/kde-heatmap.js index 0d23dd2..6df7371 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,82 +29,53 @@ 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]) - ); + 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 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; - }); - } + 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 @@ -121,23 +92,18 @@ 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); const green = Math.round(180 + 75 * (1 - intensity)); const blue = Math.round(30 * (1 - intensity)); - return `rgba(${red}, ${green}, ${blue}, ${alpha * 0.8})`; + return `rgba(${red}, ${green}, ${blue}, ${alpha * 0.7})`; } } -// 添加缓存机制 -const heatmapCache = new Map(); - /** - * 基于小程序Canvas API绘制核密度估计热力图 - 带缓存优化 + * 基于小程序Canvas API绘制核密度估计热力图 * @param {String} canvasId 画布ID * @param {Number} width 画布宽度 * @param {Number} height 画布高度 @@ -146,282 +112,152 @@ const heatmapCache = new Map(); * @returns {Promise} 绘制完成的Promise */ export function drawKDEHeatmap(canvasId, width, height, points, options = {}) { - return new Promise(async (resolve, reject) => { + const { + bandwidth = 0.8, + gridSize = 100, + range = [-4, 4], + showPoints = true, + pointColor = "rgba(255, 255, 255, 0.9)", + } = options; + + // #ifdef MP-WEIXIN + // 微信小程序使用 Canvas 2D + return new Promise((resolve, reject) => { try { - const { - bandwidth = 0.8, - gridSize = 100, - range = [-4, 4], - showPoints = false, - pointColor = "rgba(255, 255, 255, 0.9)", - } = options; + 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(); - // 创建绘图上下文 - let ctx; - try { - ctx = uni.createCanvasContext(canvasId); - if (!ctx) { - throw new Error("无法创建canvas上下文"); - } - } catch (error) { - console.error("创建canvas上下文失败:", error); - reject(new Error("Canvas上下文创建失败")); - return; - } + // 设置画布尺寸 + 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); + } + }); + // #endif + + // #ifndef MP-WEIXIN + // 其他平台沿用旧版绘制上下文 + return new Promise((resolve, reject) => { + try { + const ctx = uni.createCanvasContext(canvasId); ctx.clearRect(0, 0, width, height); - // 设置全局合成操作,让颜色叠加更自然 - 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 = () => { - // 使用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(); - // 更安全的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; // 根据处理时间动态调整 - - // 更安全的requestAnimationFrame检测 - if (typeof requestAnimationFrame === "function") { - try { - requestAnimationFrame(processChunk); - } catch (e) { - // 如果requestAnimationFrame失败,使用setTimeout - setTimeout(processChunk, delay); - } - } else { - setTimeout(processChunk, delay); - } - } else { - resolve(); - } - }; - - processChunk(); - }); - }; - - // 处理单个点的函数 - 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); - - // 确保数值有效 - 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个点,减少单次处理量 - - // 绘制原始数据点 - 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; - - // 确保坐标有效 - 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个点,减少单次处理量 + densityData.forEach(([x, y, density]) => { + 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); - ctx.beginPath(); // 开始批量路径 - let validPoints = 0; - - points.forEach((point) => { - const [x, y] = point; + points.forEach(([x, y]) => { const normalizedX = (x - range[0]) / xRange; const normalizedY = (y - range[0]) / yRange; const canvasX = normalizedX * width; const canvasY = normalizedY * height; - - // 确保坐标有效 - if ( - !isNaN(canvasX) && - !isNaN(canvasY) && - isFinite(canvasX) && - isFinite(canvasY) - ) { - ctx.arc(canvasX, canvasY, 2.5, 0, 2 * Math.PI); - validPoints++; - } + ctx.beginPath(); + ctx.arc(canvasX, canvasY, 2.5, 0, 2 * Math.PI); + ctx.fill(); }); - - // 只有在有有效点的情况下才执行填充 - 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, () => resolve()); } catch (error) { - console.error("KDE热力图绘制失败:", error); reject(error); } }); + // #endif } /** @@ -435,37 +271,88 @@ export function generateKDEHeatmapImage( points, options = {} ) { + // #ifdef MP-WEIXIN + // 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); + }); + // #endif + + // #ifndef MP-WEIXIN + // 旧版导出(使用 canvasId) return new Promise((resolve, reject) => { drawKDEHeatmap(canvasId, width, height, points, options) .then(() => { - // 生成图片 uni.canvasToTempFilePath({ - canvasId: canvasId, - width: width, - height: height, - destWidth: width * 2, // 降低分辨率避免内存问题 - destHeight: height * 2, - fileType: "png", // 明确指定png格式 - quality: 1, // 最高质量 - success: (res) => { - console.log("KDE热力图图片生成成功:", res.tempFilePath); - resolve(res.tempFilePath); - }, - fail: (error) => { - console.error("KDE热力图图片生成失败:", error); - reject(error); - }, + canvasId, + width, + height, + destWidth: width * 3, + destHeight: height * 3, + success: (res) => resolve(res.tempFilePath), + fail: reject, }); }) .catch(reject); }); + // #endif } -/** - * 清除热力图缓存 - * 在数据或参数需要强制更新时调用 - */ -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; +}; diff --git a/src/pages/point-book.vue b/src/pages/point-book.vue index be9f59e..859718e 100644 --- a/src/pages/point-book.vue +++ b/src/pages/point-book.vue @@ -31,7 +31,6 @@ const isIOS = computed(() => { return systemInfo.osName === "ios"; }); -const loadImage = ref(false); const showModal = ref(false); const showTip = ref(false); const data = ref({ @@ -76,8 +75,6 @@ const loadData = async () => { else if (result2.checkInCount >= 5) hot = 3; else if (result2.checkInCount === 7) hot = 4; uni.$emit("update-hot", hot); - loadImage.value = true; - // 异步生成热力图,不阻塞UI const generateHeatmapAsync = async () => { const weekArrows = result2.weekArrows .filter((item) => item.x && item.y) @@ -90,7 +87,7 @@ const loadData = async () => { "heatMapCanvas", rect.width, rect.height, - weekArrows, + weekArrows ); heatMapImageSrc.value = quickPath; // 延迟后再渲染精细版本 @@ -107,15 +104,13 @@ const loadData = async () => { range: [0, 1], gridSize: 120, // 更高的网格密度,减少锯齿 bandwidth: 0.15, // 稍小的带宽,让热力图更细腻 - showPoints: false, + showPoints: false } ); heatMapImageSrc.value = finalPath; - loadImage.value = false; console.log("热力图图片地址:", finalPath); } catch (error) { console.error("生成热力图图片失败:", error); - loadImage.value = false; } }; @@ -287,9 +282,22 @@ onShareTimeline(() => { :src="heatMapImageSrc" mode="aspectFill" /> - - 生成中... - + + + + +