import AtomComponent from '../../atomComponent'; import { PieGraphOptions, TooltipEventParams } from '../../models'; import { merge, type, pointDetector } from '../../utils'; import { hexToRgba } from '../../theme'; import { EventName, SeriesCategory } from '../../constants'; interface GraphicData { name: string; value: number; angle: number; enabled: boolean; selected: boolean; } interface PieHighlightPayload { id: number; } // 角度起始点对齐表盘0点位置 // 【ISSUE】https://git.woa.com/tvision/tvision-t4/issues/4 const ZERO_CLOCK_ANGLE = -Math.PI / 2; /** * 数学中常用角度来描述一个圆 * 而在canvas中用弧度来描述一个圆 * * 角度:一个圆的角度是360度 * 半径:以一个点为中心,多长为放射半径 * 周长:2 * π * r * * 弧度:是一种长度的描述 * 一个弧度有多长:一个弧度等于一个半径的长度 * 一个圆有多少个弧度:2 * π * * 最终的结论:一个角度等于多少弧度 (2 * π) / 180 即:π / 180 */ export default class Pie extends AtomComponent { private static readonly defaultOptions: PieGraphOptions = { type: 'pie', radius: [0, '60%'], // label: { // show: false, // color: 'item', // }, }; protected seriesCategory = SeriesCategory.SINGLE; private color: string; private options: PieGraphOptions; private graphicData: GraphicData[]; constructor(id: string, chart, options: PieGraphOptions) { super({ id, chart }); this.options = this.processOptions(Pie.defaultOptions, options); this.graphicData = this.processData(); this.bindInteraction(); this.setupMessagebus(); } render() { this.updateData(); this.drawPie(); } /** * 事件绑定 * sector各自绑定tap事件,点击触发阴影遮罩; * 再次点击相同sector关闭遮罩; * 点击其他sector触发阴影遮罩并关闭前sector遮罩; */ bindInteraction() { // 绑定 this.chart.getInteration().on('tap', (e) => { const { pointsInCartesian: point } = e; const { radius } = this.options; const { size: { width, height }, } = this.canvas; if (!(point.x >= 0 && point.x <= width && point.y >= 0 && point.y <= height)) return; // eslint-disable-next-line @typescript-eslint/no-unused-vars const { size } = this.canvas; const [innerRadius, outRadius] = [radius[0], radius[1]]; const centerCoord = { x: 0 + size.width / 2, y: 0 + size.height / 2, }; let preEndAngle = ZERO_CLOCK_ANGLE; let selectIndex = -1; for (let i = 0; i < this.graphicData.length; i++) { if (!this.graphicData[i].enabled) continue; const sectorData = this.graphicData[i]; const sectorArea = { originX: centerCoord.x, originY: centerCoord.y, r0: innerRadius, r1: outRadius, startAngle: preEndAngle, endAngle: preEndAngle + sectorData.angle, }; preEndAngle = preEndAngle + sectorData.angle; const isInArea = pointDetector.isInSector(point, sectorArea); if (isInArea) { selectIndex = i; break; } } if (selectIndex > -1) { // 重绘 this.graphicData = this.graphicData.map((item, index) => ({ ...item, selected: index === selectIndex ? !item.selected : false, })); this.update([this]); // 消息向外暴露 const pieTagPayload = { id: selectIndex, name: this.graphicData[selectIndex] && this.graphicData[selectIndex].name, }; this.$emit(EventName.PieTap, pieTagPayload); // 重置 selectIndex = -1; } }); // 监听外部高亮事件 this.$on(EventName.PieHighlight, (payload: PieHighlightPayload) => { const { id } = payload; // 重绘 this.graphicData = this.graphicData.map((item, index) => ({ ...item, selected: index === id ? !item.selected : false, })); this.update([this]); }); // 监听外部取消高亮事件 this.$on(EventName.PieDownplay, () => { // 重绘 this.graphicData = this.graphicData.map((item) => ({ ...item, selected: false, })); this.update([this]); }); } private setupMessagebus() { // 监听legend事件 this.$on(EventName.LegendTap, ({ componentName, dirtyComp }) => { this.graphicData = this.graphicData.map((item) => ({ ...item, enabled: item.name === componentName ? !item.enabled : item.enabled, })); this.update([dirtyComp, this]); }); } private update(dirtyComps) { this.$emit(EventName.Repaint, { dirtyComps, // legend、pie均重绘 }); } private toggleTooltip(isOpen, itemData?: any) { if (!isOpen) { this.$emit(EventName.TooltipClose); return; } const { data, area, color } = itemData; const { name, value, percent } = data; const { originX, originY, r0, r1, startAngle, endAngle } = area; const r = (r1 + r0) / 2; const angle = startAngle + (endAngle - startAngle) / 2; const x = originX + r * Math.cos(angle); const y = originY - r * Math.sin(angle); const tooltipEventParams: TooltipEventParams = { coordinate: 'polar', data: [{ name, value, color, percent }], point: { x, y, }, }; this.$emit(EventName.TooltipOpen, tooltipEventParams); } private processData() { const [data] = this.dataset; const total = data.map((item) => item.value).reduce((memo: number, current: number) => memo + current, 0); return data.map((item) => ({ ...item, angle: 2 * Math.PI * (item.value / total), percent: ((item.value / total) * 100).toFixed(2), enabled: true, selected: false, })); } private updateData() { const total = this.graphicData .filter((item) => item.enabled) .map((item) => item.value) .reduce((memo: number, current: number) => memo + current, 0); this.graphicData = this.graphicData.map((item) => ({ ...item, angle: 2 * Math.PI * (item.value / total), })); } private processStyle(options: PieGraphOptions) { const { itemStyle: rawItemStyle } = options; // const borderStyle = merge(rawItemStyle?.borderStyle, { // borderColor: rawItemStyle?.borderColor, // borderWidth: rawItemStyle?.borderWidth, // borderType: rawItemStyle?.borderType, // borderDashOffset: rawItemStyle?.borderDashOffset, // borderCap: rawItemStyle?.borderCap, // borderJoin: rawItemStyle?.borderJoin, // borderMiterLimit: rawItemStyle?.borderMiterLimit, // }); // const shadowStyle = merge(rawItemStyle?.shadowStyle, { // shadowBlur: rawItemStyle?.shadowBlur, // shadowColor: rawItemStyle?.shadowColor, // shadowOffsetX: rawItemStyle?.shadowOffsetX, // shadowOffsetY: rawItemStyle?.shadowOffsetY, // }); return { itemStyle: { ...rawItemStyle, // borderStyle, // shadowStyle, }, }; } private processRadius(radius, rawSize) { let processedRadius = []; const percent2Radius = (prepent: number | string) => { if (type.isNumber(prepent)) { return prepent; } return (Math.min(rawSize.width, rawSize.height) * (Number.parseFloat(prepent as string) / 100)) / 2; }; if (Array.isArray(radius)) { processedRadius = radius.map((item) => { if (type.isNumber(item)) { return item; } if (type.isString(item)) { return percent2Radius(item); } }); } if (type.isNumber(radius) || type.isString(radius)) { processedRadius = [0, percent2Radius(radius as number | string)]; } return processedRadius; } private processOptions(defaultOptions, userOptions): PieGraphOptions { const options: PieGraphOptions = merge(defaultOptions, userOptions); const { radius } = options; const { rawSize } = this.canvas; // radius处理 const processedRadius: Array = this.processRadius(radius, rawSize); // 颜色处理 const style = this.processStyle(options); return { ...options, ...style, radius: processedRadius, }; } // eslint-disable-next-line @typescript-eslint/no-unused-vars private drawLabel( prevStartAngle, currentAngle, color, name, outRadius: number, // eslint-disable-next-line @typescript-eslint/no-unused-vars innerRadius?: number | undefined, ) { const { canvas, options } = this; const { ctx, rawSize } = canvas; const outLine = 20; ctx.save(); ctx.beginPath(); ctx.strokeStyle = color; // if (!innerRadius) { // ctx.moveTo(rawSize.width / 2, rawSize.height / 2); // } else { // ctx.moveTo( // rawSize.width / 2 + Math.cos(prevStartAngle + currentAngle / 2) * innerRadius, // rawSize.height / 2 + Math.sin(prevStartAngle + currentAngle / 2) * innerRadius, // ); // } ctx.moveTo( rawSize.width / 2 + Math.cos(prevStartAngle + currentAngle / 2) * outRadius, rawSize.height / 2 + Math.sin(prevStartAngle + currentAngle / 2) * outRadius, ); const outX = rawSize.width / 2 + Math.cos(prevStartAngle + currentAngle / 2) * (outRadius + outLine); const outY = rawSize.height / 2 + Math.sin(prevStartAngle + currentAngle / 2) * (outRadius + outLine); ctx.lineTo(outX, outY); ctx.font = '16px MicroSoft YaHei'; const textWidth = ctx.measureText(name).width; // 画文字和下划线 if (outX > rawSize.width / 2) { // 右 ctx.lineTo(outX + textWidth, outY); ctx.textAlign = 'left'; } else { // 左 ctx.lineTo(outX - textWidth, outY); ctx.textAlign = 'right'; } ctx.stroke(); ctx.textBaseline = 'bottom'; if (options?.label?.color && options?.label?.color !== 'item') { ctx.fillStyle = options?.label?.color; } else { ctx.fillStyle = color; } ctx.fillText(name, outX, outY); ctx.closePath(); ctx.restore(); } private drawPie() { const { canvas, options: { radius, label }, } = this; const { size } = canvas; const [innerRadius, outRadius, outRadiusWidthPadding] = [radius[0], radius[1], radius[1] + 10]; let preEndAngle = ZERO_CLOCK_ANGLE; let tooltipInfoMemo: any = null; const { colors = [] } = this.chart?.style; // eslint-disable-next-line @typescript-eslint/no-unused-vars this.graphicData.forEach((sectorData, index) => { const { angle, selected, enabled } = sectorData; if (!enabled) return; // 基础款点击态 const color = hexToRgba(colors[index], 1); // 渐变款点击态 // const color = selected // ? hexToRgba(colors[index], 1) // : hexToRgba(colors[index], 0.85); // FIXME:改变alpha无法触发canvas的重绘,需要研究 // // 渐变遮罩 // const gradientColor = ctx.createRadialGradient( // origin.x, origin.y, 0, // origin.x, origin.y, outRadiusWidthPadding, // ); // gradientColor.addColorStop(0, hexToRgba(colors[index], 0)); // gradientColor.addColorStop(0.9, hexToRgba(colors[index], 0)); // gradientColor.addColorStop(0.9, hexToRgba(colors[index], 0.5)); // gradientColor.addColorStop(1, hexToRgba(colors[index], 0)); const polarOrigin = { x: size.width / 2, y: size.height / 2, }; // 环形中空区域文字渲染 if (label) { const { show, position, text: centerText, color: centerTextColot, fontWeight, fontSize, fontFamily, rotate } = label; const isShowLabel = show && position === 'center'; if (isShowLabel) { this.painter.draw('Text', { text: centerText, x: polarOrigin.x, y: polarOrigin.y, textBaseline: 'middle', fontStyle: centerTextColot, fontWeight, fontSize, fontFamily, rotate, textAlign: 'center', }); } } // 扇区 this.painter.draw('Sector', { x: polarOrigin.x, y: polarOrigin.y, r0: innerRadius, r1: outRadius, startAngle: preEndAngle, endAngle: preEndAngle + angle, anticlockwise: false, fillStyle: color, interval: 2, }); // 扇区外沿(选中时展示) const epitaxyColor = selected ? 'rgba(241,242,245,1)' : 'rgba(256, 256, 256, 1)'; this.painter.draw('Sector', { x: polarOrigin.x, y: polarOrigin.y, r0: outRadius, r1: outRadiusWidthPadding, startAngle: preEndAngle, endAngle: preEndAngle + angle, anticlockwise: false, strokeStyle: 'rgba(256, 256, 256, 1)', fillStyle: epitaxyColor, }); // tooltip(选中时展示) if (selected) { tooltipInfoMemo = { startAngle: preEndAngle, endAngle: preEndAngle + angle, sectorData, color, }; } // label // this.options.label?.show && this.drawLabel( // preEndAngle, // angle, // colors[index], // name, // outRadius, // innerRadius, // ); preEndAngle = preEndAngle + angle; }); // tooltip覆盖在顶层 if (!!tooltipInfoMemo) { const { startAngle, endAngle, color, sectorData } = tooltipInfoMemo; const { size } = this.canvas; const centerCoord = { x: 0 + size.width / 2, y: 0 + size.height / 2, }; const sectorArea = { originX: centerCoord.x, originY: centerCoord.y, r0: innerRadius, r1: outRadius, startAngle, endAngle, }; const selectItem = { color, data: sectorData, area: sectorArea, }; this.toggleTooltip(true, selectItem); } else { this.toggleTooltip(false); } } }