From 540f554af04743c55e35e6e9986a531486bd8c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=88=90?= Date: Wed, 3 Dec 2025 23:18:42 +0800 Subject: [PATCH] 1 --- src/components/Radar/indexV2.tsx | 384 +++++++++++++++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 src/components/Radar/indexV2.tsx diff --git a/src/components/Radar/indexV2.tsx b/src/components/Radar/indexV2.tsx new file mode 100644 index 0000000..4881b67 --- /dev/null +++ b/src/components/Radar/indexV2.tsx @@ -0,0 +1,384 @@ +import Taro from "@tarojs/taro"; +import { View, Canvas } from "@tarojs/components"; +import { useEffect, forwardRef, useImperativeHandle } from "react"; +import shareLogoSvg from "@/static/ntrp/ntrp_share_logo.svg"; + +interface RadarChartV2Props { + data: [string, number][]; + title?: string; + ntrpLevel?: string; + qrCodeUrl?: string; + bottomText?: string; +} + +export interface RadarChartV2Ref { + generateImage: () => Promise; + generateFullImage: (options: { + title?: string; + ntrpLevel?: string; + qrCodeUrl?: string; + bottomText?: string; + width?: number; + height?: number; + }) => Promise; +} + +const RadarChartV2 = forwardRef((props, ref) => { + const { data, title, ntrpLevel, qrCodeUrl, bottomText } = props; + + const maxValue = 100; + const levels = 5; + const radius = 100; + const center = { x: 160, y: 160 }; + + useEffect(() => { + if (data.length > 0) { + const { texts, vals } = data.reduce( + (res, item) => { + const [text, val] = item; + return { + texts: [...res.texts, text], + vals: [...res.vals, val], + }; + }, + { texts: [], vals: [] } + ); + renderCanvas(texts, vals); + } + }, [data]); + + function renderCanvas(labels, values) { + const query = Taro.createSelectorQuery(); + query + .select("#radarCanvasV2") + .fields({ node: true, size: true }) + .exec((res) => { + const canvas = res[0].node as HTMLCanvasElement; + const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; + const dpr = Taro.getWindowInfo().pixelRatio; + canvas.width = res[0].width * dpr; + canvas.height = res[0].height * dpr; + ctx.scale(dpr, dpr); + + // 启用抗锯齿,消除锯齿 + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + // 设置线条端点样式为圆形,减少锯齿 + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + // === 绘制圆形网格 === + 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"; + } + // 设置线条宽度为1px,确保清晰 + ctx.lineWidth = 1; + ctx.stroke(); + } + + // === 坐标轴 & 标签 === + labels.forEach((label, i) => { + const angle = ((Math.PI * 2) / labels.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; + ctx.stroke(); + + // 标签 + const offset = 10; + const textX = center.x + (radius + offset) * Math.cos(angle); + const textY = center.y + (radius + offset) * Math.sin(angle); + + ctx.font = "12px sans-serif"; + ctx.fillStyle = "#333"; + ctx.textBaseline = "middle"; + + if ( + Math.abs(angle) < 0.01 || + Math.abs(Math.abs(angle) - Math.PI) < 0.01 + ) { + ctx.textAlign = "center"; + } else if (angle > -Math.PI / 2 && angle < Math.PI / 2) { + ctx.textAlign = "left"; + } else { + ctx.textAlign = "right"; + } + + ctx.fillText(label, textX, textY); + }); + + // === 数据区域 === + const baseRadius = (radius / levels) * 1; // 第二个环为起点 + const usableRadius = radius - baseRadius; + + ctx.beginPath(); + values.forEach((val, i) => { + const angle = ((Math.PI * 2) / labels.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; + 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.getImageInfo({ + src, + success: ({ width, height }) => resolve({ width, height }), + fail: reject, + }); + }); + } + + // 格式化 NTRP 显示 + function formatNtrpDisplay(level: string): string { + if (!level) return ""; + const num = parseFloat(level); + return num % 1 === 0 ? num.toFixed(0) : num.toFixed(1); + } + + useImperativeHandle(ref, () => ({ + // 生成原始雷达图 + generateImage: () => + new Promise((resolve, reject) => { + const query = Taro.createSelectorQuery(); + query + .select("#radarCanvasV2") + .fields({ node: true, size: true }) + .exec((res) => { + const canvas = res[0].node; + Taro.canvasToTempFilePath({ + canvas, + success: (res) => resolve(res.tempFilePath), + fail: (err) => reject(err), + }); + }); + }), + + // 生成完整图片(包含标题、雷达图、底部文字和二维码) + generateFullImage: async (options: { + title?: string; + ntrpLevel?: string; + qrCodeUrl?: string; + bottomText?: string; + width?: number; + height?: number; + }) => { + return new Promise(async (resolve, reject) => { + try { + // 默认尺寸,根据设计稿调整 + const width = options.width || 750; // 设计稿宽度 + const height = options.height || 1334; // 设计稿高度 + + // 先获取雷达图 + const radarImageUrl = await new Promise((resolveRadar, rejectRadar) => { + const query = Taro.createSelectorQuery(); + query + .select("#radarCanvasV2") + .fields({ node: true, size: true }) + .exec((res) => { + const canvas = res[0].node; + Taro.canvasToTempFilePath({ + canvas, + success: (res) => resolveRadar(res.tempFilePath), + fail: (err) => rejectRadar(err), + }); + }); + }); + + // 创建导出画布 + 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"); + const dpr = Taro.getWindowInfo().pixelRatio; + canvas.width = width * dpr; + canvas.height = height * dpr; + ctx.scale(dpr, dpr); + + // 启用抗锯齿 + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + + // 绘制背景 + ctx.fillStyle = "#E9FDF8"; + ctx.fillRect(0, 0, width, height); + + // 根据设计稿调整内边距和布局 + const topPadding = 60; // 顶部内边距 + const sidePadding = 40; // 左右内边距 + const bottomPadding = 30; // 底部内边距 + + let currentY = topPadding; + + // 绘制标题 - 根据设计稿调整字体大小和间距 + if (options.title) { + ctx.fillStyle = "#000000"; + ctx.font = "400 28px sans-serif"; // 设计稿字体大小 + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + ctx.fillText(options.title, sidePadding, currentY); + currentY += 44; // 标题到 NTRP 等级的间距 + } + + // 绘制 NTRP 等级 - 根据设计稿调整字体大小和颜色 + if (options.ntrpLevel) { + ctx.fillStyle = "#00E5AD"; + ctx.font = "bold 48px sans-serif"; // 设计稿字体大小 + ctx.fillText(`NTRP ${formatNtrpDisplay(options.ntrpLevel)}`, sidePadding, currentY); + currentY += 72; // NTRP 等级到雷达图的间距 + } + + // 绘制雷达图 - 根据设计稿调整尺寸和位置 + try { + const radarImg = await loadImage(canvas, radarImageUrl); + // 计算雷达图尺寸,根据设计稿调整 + const radarMaxSize = width - sidePadding * 2; + const radarActualSize = Math.min(radarMaxSize, 670); // 设计稿雷达图尺寸 + const radarX = (width - radarActualSize) / 2; // 居中 + const radarY = currentY; + ctx.drawImage(radarImg, radarX, radarY, radarActualSize, radarActualSize); + currentY += radarActualSize + 60; // 雷达图到底部的间距 + } catch (error) { + console.error("Failed to load radar image:", error); + } + + // 绘制底部区域 - 根据设计稿调整 + const qrSize = 100; // 设计稿二维码尺寸 + + // 计算底部区域布局 - 从底部向上计算 + const bottomTextHeight = 28; // 底部文字高度 + const bottomSpacing = 30; // 底部间距 + + // 绘制二维码 - 右下角 + const qrX = width - sidePadding - qrSize; + const qrY = height - bottomPadding - qrSize - bottomTextHeight - 20; // 在文字上方 + + if (options.qrCodeUrl) { + try { + const qrImg = await loadImage(canvas, options.qrCodeUrl); + ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize); + } catch (error) { + console.error("Failed to load QR code:", error); + } + } + + // 绘制底部 SVG logo 图片 - 根据设计稿,SVG 包含 logo 和文字 + try { + const logoSvgPath = shareLogoSvg; + const logoSvgImg = await loadImage(canvas, logoSvgPath); + const logoSvgWidth = 235; // SVG 原始宽度 + const logoSvgHeight = 28; // SVG 原始高度 + const logoSvgX = sidePadding; // 左边对齐 + const logoSvgY = height - bottomPadding - logoSvgHeight; // 底部对齐 + + ctx.drawImage( + logoSvgImg, + logoSvgX, + logoSvgY, + logoSvgWidth, + logoSvgHeight + ); + } catch (error) { + console.error("Failed to load logo SVG:", error); + } + + // 导出图片 + Taro.canvasToTempFilePath({ + canvas, + fileType: 'png', + quality: 1, + success: (res) => { + resolve(res.tempFilePath); + }, + fail: (err) => { + reject(err); + }, + }); + }); + } catch (error) { + reject(error); + } + }); + }, + })); + + return ( + + + {/* 隐藏的导出画布 */} + + + ); +}); + +RadarChartV2.displayName = "RadarChartV2"; + +export default RadarChartV2; +