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"
/>
-
- 生成中...
-
+
+
+
+
+