import Taro from "@tarojs/taro"; import { View, Canvas } from "@tarojs/components"; import { forwardRef, useImperativeHandle } from "react"; import shareLogoSvg from "@/static/ntrp/ntrp_share_logo.png"; import docCopyPng from "@/static/ntrp/ntrp_doc_copy.png"; import { OSS_BASE_URL } from "@/config/api"; interface RadarChartV2Props { data: [string, number][]; title?: string; ntrpLevel?: string; levelDescription?: string; avatarUrl?: string; qrCodeUrl?: string; bottomText?: string; } export interface RadarChartV2Ref { generateImage: () => Promise; generateFullImage: (options: { title?: string; ntrpLevel?: string; levelDescription?: string; avatarUrl?: string; qrCodeUrl?: string; bottomText?: string; width?: number; height?: number; }) => Promise; } const RadarChartV2 = forwardRef((props, ref) => { const { data } = props; const maxValue = 100; const levels = 5; // 在 exportCanvasV2 中绘制雷达图的函数 function drawRadarChart(ctx: CanvasRenderingContext2D, radarX: number, radarY: number, radarSize: number) { // 雷达图中心点位置(radarSize 已经是2倍图尺寸) const center = { x: radarX + radarSize / 2, y: radarY + radarSize / 2 }; // 计算实际半径(radarSize 是直径,半径是直径的一半) const radius = radarSize / 2; // 启用抗锯齿 ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; // 解析数据 const { texts, vals } = data.reduce( (res, item) => { const [text, val] = item; return { texts: [...res.texts, text], vals: [...res.vals, val], }; }, { texts: [], vals: [] } ); // === 绘制圆形网格 === for (let i = levels; i >= 1; i--) { const r = (radius / levels) * i; ctx.beginPath(); ctx.arc(center.x, center.y, r, 0, Math.PI * 2); if (i % 2 === 1) { ctx.fillStyle = "#fff"; ctx.fill(); } else { ctx.fillStyle = "#CAFCF0"; ctx.fill(); } // 根据层级设置不同的线条颜色 if (i === 1) { ctx.strokeStyle = "#E5E5E5"; } else if (i <= 3) { ctx.strokeStyle = "#E0E0E0"; } else { ctx.strokeStyle = "#D5D5D5"; } ctx.lineWidth = 1 * (radarSize / 200); // 根据2倍图调整线宽 ctx.stroke(); } // === 坐标轴 & 标签 === texts.forEach((label, i) => { const angle = ((Math.PI * 2) / texts.length) * i - Math.PI / 2; const x = center.x + radius * Math.cos(angle); const y = center.y + radius * Math.sin(angle); // 坐标轴 ctx.beginPath(); ctx.moveTo(center.x, center.y); ctx.lineTo(x, y); ctx.strokeStyle = "#E0E0E0"; ctx.lineWidth = 1 * (radarSize / 200); // 根据2倍图调整线宽 ctx.stroke(); // 标签:沿轴线外侧延伸,文字中心对齐轴线端点(与 index.tsx 一致) const labelOffset = 28 * (radarSize / 200); // 文字距离圆圈的偏移量(2倍图) const textX = center.x + (radius + labelOffset) * Math.cos(angle); const textY = center.y + (radius + labelOffset) * Math.sin(angle); ctx.font = `${12 * (radarSize / 200)}px sans-serif`; // 根据2倍图调整字体大小 ctx.fillStyle = "#333"; ctx.textBaseline = "middle"; ctx.textAlign = "center"; ctx.fillText(label, textX, textY); }); // === 数据区域 === const baseRadius = (radius / levels) * 1; // 第二个环为起点 const usableRadius = radius - baseRadius; ctx.beginPath(); vals.forEach((val, i) => { const angle = ((Math.PI * 2) / texts.length) * i - Math.PI / 2; const r = baseRadius + (val / maxValue) * usableRadius; const x = center.x + r * Math.cos(angle); const y = center.y + r * Math.sin(angle); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.closePath(); ctx.fillStyle = "rgba(0,200,180,0.3)"; ctx.fill(); ctx.strokeStyle = "#00c8b4"; ctx.lineWidth = 3 * (radarSize / 200); // 根据2倍图调整线宽 ctx.stroke(); } // 加载图片工具函数 function loadImage(canvas: any, src: string): Promise { return new Promise((resolve, reject) => { const img = canvas.createImage(); img.onload = () => resolve(img); img.onerror = (err) => reject(err); img.src = src; }); } // 获取图片信息(宽高) function getImageInfo(src: string): Promise<{ width: number; height: number }> { return new Promise((resolve, reject) => { (Taro as any).getImageInfo({ src, success: (res: any) => resolve({ width: res.width, height: res.height }), fail: reject, }); }); } // 绘制圆角矩形 function roundRect(ctx: any, x: number, y: number, width: number, height: number, radius: number) { ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.lineTo(x + width - radius, y); ctx.quadraticCurveTo(x + width, y, x + width, y + radius); ctx.lineTo(x + width, y + height - radius); ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); ctx.lineTo(x + radius, y + height); ctx.quadraticCurveTo(x, y + height, x, y + height - radius); ctx.lineTo(x, y + radius); ctx.quadraticCurveTo(x, y, x + radius, y); ctx.closePath(); } // 格式化 NTRP 显示 function formatNtrpDisplay(level: string): string { if (!level) return ""; // 检查是否包含 + 号 const hasPlus = level.includes("+"); const num = parseFloat(level); if (isNaN(num)) return level; const formatted = num % 1 === 0 ? num.toFixed(0) : num.toFixed(1); return hasPlus ? `${formatted}+` : formatted; } useImperativeHandle(ref, () => ({ // 生成原始雷达图(已废弃,现在直接在 exportCanvasV2 中绘制) generateImage: () => Promise.resolve(""), // 生成完整图片(包含标题、雷达图、底部文字和二维码) generateFullImage: async (options: { title?: string; ntrpLevel?: string; levelDescription?: string; avatarUrl?: string; qrCodeUrl?: string; bottomText?: string; width?: number; height?: number; }) => { return new Promise(async (resolve, reject) => { try { // 使用2倍图提高图片质量 const scale = 2; // 2倍图 const baseWidth = 350; // 基础宽度 const baseHeight = 600; // 基础高度 const width = baseWidth * scale; // 实际宽度 700 const height = baseHeight * scale; // 实际高度 1200 // 创建导出画布 const query = Taro.createSelectorQuery(); query .select("#exportCanvasV2") .fields({ node: true, size: true }) .exec(async (res) => { if (!res || !res[0]) { reject(new Error("Canvas not found")); return; } const canvas = res[0].node; const ctx = canvas.getContext("2d"); // 使用2倍图尺寸 canvas.width = width; canvas.height = height; // 启用抗锯齿 ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; // 绘制背景 - 使用 share_bg.png 背景图,撑满整个画布(从 OSS 动态加载) try { const shareBgUrl = `${OSS_BASE_URL}/images/share_bg.png`; const bgImg = await loadImage(canvas, shareBgUrl); ctx.drawImage(bgImg, 0, 0, width, height); } catch (error) { console.error("Failed to load background image:", error); // 如果加载失败,使用白色背景作为兜底 ctx.fillStyle = "#FFFFFF"; ctx.fillRect(0, 0, width, height); } // 根据设计稿调整内边距和布局(2倍图) const topPadding = 24 * scale; // 设计稿顶部内边距 const sidePadding = 28 * scale; // 设计稿左右内边距(Frame 1912055062 的 x: 28) let currentY = topPadding; // 绘制用户头像和装饰图片 - 根据设计稿尺寸(2倍图) if (options.avatarUrl) { try { const avatarSize = 43.46 * scale; // 设计稿头像尺寸 const avatarImg = await loadImage(canvas, options.avatarUrl); const avatarInfo = await getImageInfo(options.avatarUrl); // 头像区域总宽度(头像 + 装饰图片重叠部分) const avatarWrapWidth = 84.7 * scale; // 设计稿 Frame 1912055063 宽度 const avatarX = sidePadding + (294 * scale - avatarWrapWidth) / 2; // 294 是 Frame 1912055062 宽度,居中 const avatarY = currentY; // 绘制头像圆形背景 ctx.save(); ctx.beginPath(); ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2); ctx.fillStyle = "#FFFFFF"; ctx.fill(); ctx.strokeStyle = "#EFEFEF"; ctx.lineWidth = 1 * scale; ctx.stroke(); // 计算头像绘制尺寸,保持宽高比 const innerSize = avatarSize - 1.94 * scale; // 内部可用尺寸 const avatarAspectRatio = avatarInfo.width / avatarInfo.height; let drawWidth = innerSize; let drawHeight = innerSize; let drawX = avatarX + 0.97 * scale; let drawY = avatarY + 0.97 * scale; // 根据宽高比调整尺寸,保持比例不变形 if (avatarAspectRatio > 1) { // 图片更宽,以高度为准 drawWidth = innerSize * avatarAspectRatio; drawX = avatarX + avatarSize / 2 - drawWidth / 2; } else { // 图片更高或正方形,以宽度为准 drawHeight = innerSize / avatarAspectRatio; drawY = avatarY + avatarSize / 2 - drawHeight / 2; } // 绘制头像(圆形裁剪) ctx.beginPath(); ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2 - 0.97 * scale, 0, Math.PI * 2); ctx.clip(); ctx.drawImage(avatarImg, drawX, drawY, drawWidth, drawHeight); ctx.restore(); // 绘制装饰图片(DocCopy)- 在头像右侧 const addonSize = 42 * scale; // 装饰图片和头像一样大 const addonX = avatarX + avatarSize - 5 * scale; // 重叠部分 const addonY = avatarY; const addonRotation = 8 * (Math.PI / 180); // 旋转 8 度 try { const docCopyImg = await loadImage(canvas, docCopyPng); ctx.save(); // 移动到旋转中心 const centerX = addonX + addonSize / 2; const centerY = addonY + addonSize / 2; ctx.translate(centerX, centerY); ctx.rotate(addonRotation); // 绘制装饰图片背景(圆角矩形,带渐变) const borderRadius = 9.66 * scale; // 设计稿圆角 ctx.fillStyle = "#FFFFFF"; ctx.beginPath(); roundRect(ctx, -addonSize / 2, -addonSize / 2, addonSize, addonSize, borderRadius); ctx.fill(); // 添加渐变背景色 ctx.globalAlpha = 0.2; ctx.fillStyle = "rgba(89, 255, 214, 1)"; ctx.fill(); ctx.globalAlpha = 1.0; ctx.strokeStyle = "#FFFFFF"; ctx.lineWidth = 1.93 * scale; // 设计稿 strokeWeight ctx.stroke(); // 绘制装饰图片 const docSize = 26.18 * scale; // 设计稿内部图片尺寸 const docRotation = -7 * (Math.PI / 180); // 内部旋转 -7 度 ctx.rotate(docRotation); ctx.drawImage(docCopyImg, -docSize / 2, -docSize / 2, docSize, docSize); ctx.restore(); } catch (error) { console.error("Failed to load docCopy image:", error); } currentY += (48 + 20) * scale; // 头像区域高度 + gap } catch (error) { console.error("Failed to load avatar image:", error); } } // 绘制文字区域 - 根据设计稿样式,居中对齐(2倍图) const textCenterX = sidePadding + (294 * scale) / 2; // Frame 1912055062 的中心 const textGap = 6 * scale; // 设计稿 gap: 6px // 绘制标题 - 14px, font-weight: 300, line-height: 1.2(2倍图) if (options.title) { ctx.fillStyle = "#000000"; ctx.font = `300 ${14 * scale}px Noto Sans SC, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "top"; ctx.fillText(options.title, textCenterX, currentY); currentY += 14 * 1.2 * scale + textGap; // line-height: 1.2 + gap } // 绘制 NTRP 等级 - 36px, font-weight: 900, "NTRP" 黑色,等级数字 #00E5AD(2倍图) if (options.ntrpLevel) { ctx.font = `900 ${36 * scale}px Noto Sans SC, sans-serif`; ctx.textBaseline = "top"; const ntrpText = "NTRP"; const levelText = formatNtrpDisplay(options.ntrpLevel); const ntrpWidth = ctx.measureText(ntrpText).width; const levelWidth = ctx.measureText(levelText).width; const totalWidth = ntrpWidth + levelWidth; // 设计稿中紧挨着 const startX = textCenterX - totalWidth / 2; // 绘制 "NTRP"(黑色) ctx.fillStyle = "#000000"; ctx.textAlign = "left"; ctx.fillText(ntrpText, startX, currentY); // 绘制等级数字(#00E5AD) ctx.fillStyle = "#00E5AD"; ctx.fillText(levelText, startX + ntrpWidth, currentY); currentY += 36 * 1.222 * scale + textGap; // line-height: 1.222 + gap } // 绘制描述文本 - 16px, font-weight: 600, line-height: 1.4(2倍图) if (options.levelDescription) { ctx.fillStyle = "#000000"; ctx.font = `600 ${16 * scale}px PingFang SC, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "top"; ctx.fillText(options.levelDescription, textCenterX, currentY); // 计算到雷达图的间距:雷达图 y: 252 - 当前 Y const radarY = 252 * scale; // 设计稿雷达图位置 currentY = radarY; // 直接设置到雷达图位置 } // 直接绘制雷达图 - 根据设计稿调整尺寸和位置(2倍图) const radarSize = 200 * scale; // 固定雷达图尺寸为 200*200 const radarX = 75 * scale; // 设计稿雷达图 x 位置 const radarY = currentY; // 直接在 exportCanvasV2 中绘制雷达图 drawRadarChart(ctx, radarX, radarY, radarSize); currentY += radarSize; // 雷达图高度 // 绘制底部区域 - 根据设计稿调整(2倍图) const qrSize = 64 * scale; // 设计稿二维码尺寸:64*64 const qrX = 276 * scale; // 设计稿二维码 x 位置 const qrY = 523 * scale; // 设计稿二维码 y 位置 const bottomTextContent = options.bottomText || "长按识别二维码,快来加入,有你就有场!"; // 绘制底部文字 - 设计稿:fontSize: 12, fontWeight: 400, line-height: 1.5(2倍图) ctx.fillStyle = "rgba(0, 0, 0, 0.45)"; ctx.font = `400 ${12 * scale}px Noto Sans SC, sans-serif`; ctx.textAlign = "left"; ctx.textBaseline = "top"; const textX = 20 * scale; // 设计稿文字 x 位置 const maxTextWidth = qrX - textX - 10 * scale; // 到二维码的间距 // 计算文字行数 const bottomWords = bottomTextContent.split(""); let bottomLine = ""; let textLines: string[] = []; const lineHeight = 12 * 1.5 * scale; // fontSize * line-height for (let i = 0; i < bottomWords.length; i++) { const testBottomLine = bottomLine + bottomWords[i]; const metrics = ctx.measureText(testBottomLine); if (metrics.width > maxTextWidth && i > 0) { textLines.push(bottomLine); bottomLine = bottomWords[i]; } else { bottomLine = testBottomLine; } } if (bottomLine) { textLines.push(bottomLine); } // 计算文字总高度 const textTotalHeight = textLines.length * lineHeight; // 计算二维码底部位置 const qrBottomY = qrY + qrSize; // 让文字底部和二维码底部对齐 const textY = qrBottomY - textTotalHeight; // 绘制顶部标题和图标 - 设计稿 Frame 2036081426(2倍图) // 图标应该在文字上方,gap: 4px const iconGap = 4 * scale; // 图标和文字之间的间距 const topTitleX = textX; // 绘制上面的标题 logo try { const iconSize = 28 * scale; const iconImg = await loadImage(canvas, shareLogoSvg); // 图标位置:文字顶部上方 iconSize + gap const iconY = textY - iconSize - iconGap; ctx.drawImage(iconImg, topTitleX, iconY, 235 * scale, iconSize); } catch (error) { console.error("Failed to load icon:", error); } // 绘制底部文字 textLines.forEach((lineText, index) => { ctx.fillText(lineText, textX, textY + index * lineHeight); }); // 绘制二维码 - 设计稿位置(带白色背景、边框、阴影和圆角) if (options.qrCodeUrl) { try { const qrImg = await loadImage(canvas, options.qrCodeUrl); // 二维码样式参数(2倍图) const borderWidth = 2 * scale; // 边框宽度 2px const borderRadius = 10 * scale; // 圆角 10px const shadowOffsetX = 0; // 阴影 X 偏移 const shadowOffsetY = 1.04727 * scale; // 阴影 Y 偏移 1.04727px const shadowBlur = 9.42545 * scale; // 阴影模糊 9.42545px const shadowColor = "rgba(0, 0, 0, 0.12)"; // 阴影颜色 // 二维码实际绘制区域(留出边框空间) const qrInnerSize = qrSize - borderWidth * 2; const qrInnerX = qrX + borderWidth; const qrInnerY = qrY + borderWidth; // 保存上下文状态 ctx.save(); // 设置阴影 ctx.shadowOffsetX = shadowOffsetX; ctx.shadowOffsetY = shadowOffsetY; ctx.shadowBlur = shadowBlur; ctx.shadowColor = shadowColor; // 绘制白色背景(圆角矩形) ctx.fillStyle = "#FFFFFF"; roundRect(ctx, qrX, qrY, qrSize, qrSize, borderRadius); ctx.fill(); // 绘制白色边框 ctx.strokeStyle = "#FFFFFF"; ctx.lineWidth = borderWidth; roundRect(ctx, qrX, qrY, qrSize, qrSize, borderRadius); ctx.stroke(); // 清除阴影(二维码图片不需要阴影) ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0; ctx.shadowBlur = 0; ctx.shadowColor = "transparent"; // 绘制二维码图片(在圆角矩形内) ctx.save(); // 创建圆角裁剪区域 roundRect(ctx, qrInnerX, qrInnerY, qrInnerSize, qrInnerSize, borderRadius - borderWidth); ctx.clip(); // 绘制二维码图片 ctx.drawImage(qrImg, qrInnerX, qrInnerY, qrInnerSize, qrInnerSize); ctx.restore(); // 恢复上下文状态 ctx.restore(); } catch (error) { console.error("Failed to load QR code:", error); } } // 导出图片 Taro.canvasToTempFilePath({ canvas, fileType: 'png', quality: 1, success: (res) => { resolve(res.tempFilePath); }, fail: (err) => { reject(err); }, }); }); } catch (error) { reject(error); } }); }, })); return ( {/* 隐藏的导出画布 - 2倍图尺寸 */} ); }); RadarChartV2.displayName = "RadarChartV2"; export default RadarChartV2;