import { LegendOptions } from '../../models'; import AtomComponent from '../../atomComponent'; import { hexToRgba } from '../../theme'; import { merge, type, pointDetector } from '../../utils'; import { EventName, SeriesCategory } from '../../constants'; import { BaseToken } from '../../theme/index'; interface GraphicData { name: string; enabled: boolean; } const name = 'legend'; export default class Legend extends AtomComponent { private static readonly rectWidth = 30; private static readonly rectHeight = 10; protected seriesCategory = SeriesCategory.NONE; private readonly defaultOptions: LegendOptions = { icon: 'circle', show: true, textStyle: { overflow: 'none', ellipsis: '...', }, justify: 'left', }; private options: LegendOptions; private graphicData: (GraphicData | null)[]; constructor(chart, options: LegendOptions) { super({ name, chart }); this.options = this.processOptions(options, this.defaultOptions); this.graphicData = this.processData(); this.bindInteraction(); } render() { const { options } = this; if (!options || !options.show) return; this.drawLegend(); } /** * 图例展示逻辑 * 默认: 在图表下方50px开始排布 * 自定义:根据配置项top相对画布顶端布局 */ private getLengendPosition(): number | undefined { const { options: { top }, canvas: { padding, size }, } = this; const defaultYPos = - 50; // 默认 const canvasTopYPos = size.height + padding.top; const isCustomPos = top > -1; return isCustomPos ? (canvasTopYPos - top) : defaultYPos; } private drawLegend() { const { chart, canvas: { size, ctx }, graphicData, } = this; const { style: { colors = [] }, } = chart; const symbolRadius = 5; const symbolToTextGap = 4; const TOP = this.getLengendPosition(); // const legendToLegendGap = symbolToTextGap * 2; const legendWidths = graphicData.map(({ name }) => { const legendWidth = symbolRadius * 2 + symbolToTextGap + ctx.measureText(name).width; return legendWidth; }); const generateLegends = () => { const legendData = [...graphicData]; const tooltips = []; let legendWidthsOfCurrentRow = []; let tooltipsOfCurrentRow = []; legendWidths.forEach((legendWidth, index) => { legendWidthsOfCurrentRow.push(legendWidth); // 如果单个legend宽度大于canvas的宽度,则该legend独占一行 if (legendWidth > size.width) { tooltipsOfCurrentRow.length && tooltips.push(tooltipsOfCurrentRow); tooltipsOfCurrentRow = [legendData[index]]; tooltips.push(tooltipsOfCurrentRow); tooltipsOfCurrentRow = []; legendWidthsOfCurrentRow = []; } else { const maxColCount = tooltips?.length ? Math.max(...tooltips.map(item => item.length)) : 0; const cellCount = maxColCount === 1 || maxColCount === 0 ? legendWidthsOfCurrentRow.length : maxColCount; const cellWidth = size.width / cellCount; const isExistBig = !!legendWidthsOfCurrentRow.find(width => width > cellWidth); if (!isExistBig) { if (maxColCount > 1 && tooltipsOfCurrentRow.length === maxColCount) { tooltips.push(tooltipsOfCurrentRow); tooltipsOfCurrentRow = [legendData[index]]; legendWidthsOfCurrentRow = [legendWidth]; } tooltipsOfCurrentRow.push(legendData[index]); } else { tooltips.push(tooltipsOfCurrentRow); tooltipsOfCurrentRow = [legendData[index]]; legendWidthsOfCurrentRow = [legendWidth]; } } if (index === legendWidths.length - 1) { tooltips.push(tooltipsOfCurrentRow); tooltipsOfCurrentRow = []; legendWidthsOfCurrentRow = []; } }); return tooltips; }; const legends = generateLegends(); const generateTruncateText = (name: string) => { const { textStyle } = this.options; if (textStyle?.overflow !== 'truncate') return name; ctx.save(); ctx.font = 'normal 10px PingFang SC serif'; const maxTextWidth = size.width - symbolRadius * 2 - symbolToTextGap; const names = name.split(''); let i = names.length; while (i > 0) { if (ctx.measureText(`${name.substring(0, i)}${textStyle.ellipsis}`).width <= maxTextWidth) { ctx.restore(); return `${name.substring(0, i)}${textStyle.ellipsis}`; } i -= 1; } }; const computeCellStartX = (cellWidth, itemIndex) => { switch (this.options.justify) { case 'left': return 0; case 'center': return (cellWidth - legendWidths[itemIndex]) / 2; case 'right': return cellWidth - legendWidths[itemIndex]; } }; const getPaintType = (type) => { switch (type) { case 'rect': return 'Rect'; default: return 'Circle'; } }; const getRectFromCircle = (x, y, radius) => ({ x: x - radius, y: y - radius, width: 2 * radius, height: 2 * radius, }); const drawLegend = (data, rowIndex) => { const cellNum = rowIndex === 0 || legends[rowIndex - 1].length === 1 ? data.length : legends[rowIndex - 1].length; const cellWidth = size.width / cellNum; const paintType = getPaintType(this.options.icon); data.forEach(({ name, enabled, itemStyle }, colIndex) => { const itemIndex = (rowIndex === 0 ? 0 : legends[rowIndex - 1].length) + colIndex; const color = hexToRgba(colors[itemIndex], enabled ? 1 : 0.3); if (this.options.orient === 'vertical') { const x = 20; const y = size.height - 20 - colIndex * (Legend.rectHeight + 10); const rect = getRectFromCircle(x, y, symbolRadius); if (paintType === 'Circle') { this.painter.draw('Circle', { x, y, r: symbolRadius, width: Legend.rectWidth, height: -Legend.rectHeight, fillStyle: itemStyle?.color || color, strokeStyle: color, decal: itemStyle?.decal, }); } if (paintType === 'Rect') { this.painter.draw('Rect', { x: rect.x, y: rect.y, width: rect.width, height: rect.height, fillStyle: itemStyle?.color || color, strokeStyle: color, decal: itemStyle?.decal, }); } this.painter.draw('Text', { text: name, x: 20 + Legend.rectWidth + 10, y: 20 + colIndex * (Legend.rectHeight + 10), textBaseline: 'alphabetic', fontStyle: BaseToken?.COLOR?.FONTGRAY_2, }); } else { const x = colIndex * cellWidth + symbolRadius + computeCellStartX(cellWidth, itemIndex) ; const y = TOP - Legend.rectHeight / 2 - rowIndex * 20; const rect = getRectFromCircle(x, y, symbolRadius); if (paintType === 'Circle') { this.painter.draw('Circle', { x, y, r: symbolRadius, fillStyle: itemStyle?.color || color, strokeStyle: color, decal: itemStyle?.decal, }); } if (paintType === 'Rect') { this.painter.draw('Rect', { x: rect.x, y: rect.y, width: rect.width, height: rect.height, fillStyle: itemStyle?.color || color, strokeStyle: color, decal: itemStyle?.decal, }); } this.painter.draw('Text', { text: legendWidths[itemIndex] > size.width ? generateTruncateText(name) : name, x: colIndex * cellWidth + symbolRadius * 2 + symbolToTextGap + computeCellStartX(cellWidth, itemIndex), y: TOP - Legend.rectHeight / 2 - rowIndex * 20, textBaseline: 'middle', fontStyle: BaseToken?.COLOR?.FONTGRAY_2, fontFamily: 'PingFang SC serif', fontSize: 10, }); } }); }; legends.forEach((rowLegends, rowIndex) => { drawLegend(rowLegends, rowIndex); }); } private update(graphData: GraphicData) { const { name, enabled } = graphData; this.$emit(EventName.LegendTap, { componentName: name, enabled, dirtyComp: this, }); } private processData() { const { series } = this; let data = []; const isMultiGraph = series.every(({ type }) => ['line', 'bar'].includes(type)); if (isMultiGraph) { data = series; } else { data = series[0].data; } return (data || []).map(item => ({ name: this.generateLegendName(item.name), enabled: true, itemStyle: item.itemStyle, })); } private getRectBorders({ x, y, width, height }) { const divideCount = 20; const rects = []; // 下边 由左至右 for (let i = 0; i < divideCount; i++) { rects.push({ x: x + (width / divideCount) * i, y, }); } // 右边 由下至上 for (let i = 0; i < divideCount; i++) { rects.push({ x: x + width, y: y + (height / divideCount) * i, }); } // 上边 由右至左 for (let i = 0; i < divideCount; i++) { rects.push({ x: x + width - (width / divideCount) * i, y: y + height, }); } // 左边 由上至下 for (let i = 0; i < divideCount; i++) { rects.push({ x, y: y + height - (height / divideCount) * i, }); } return rects; } private bindInteraction() { this.chart.getInteration().on('tap', (e) => { const { pointsInCartesian: point } = e; const { canvas: { size }, graphicData, } = this; const { width, height } = size; if (point.x >= 0 && point.x <= width && point.y >= 0 && point.y <= height) return; const legendWidth = size.width / graphicData.length; const legendHeight = 30; const TOP = this.getLengendPosition(); let selectIndex = -1; for (let i = 0; i < graphicData.length; i++) { const legendLeft = 0 + i * legendWidth; const coordinates = this.getRectBorders({ x: legendLeft, y: TOP, // TODO: legend增加换行特性时,y需要调整 width: legendWidth, height: -legendHeight, }); const isInRect = pointDetector.isInPolyRect(point, coordinates, false); if (isInRect) { selectIndex = i; break; } } if (selectIndex > -1) { this.graphicData = this.graphicData.map((item, index) => ({ ...item, enabled: index === selectIndex ? !item.enabled : item.enabled, })); this.update(this.graphicData[selectIndex]); selectIndex = -1; } }); } private generateLegendName(name: string) { const { options: { formatter }, } = this; if (!formatter) return name; if (type.isString(formatter)) { return (formatter as string).replace(/\{(\s*name\s*)\}/, name); } if (type.isFunction(formatter)) { return (formatter as Function)(name); } return name; } private processOptions(userOptions, defaultOptions) { const options = merge(defaultOptions, userOptions); return options; } }