UNPKG

zz-chart

Version:

Alauda Chart components by Alauda Frontend Team

464 lines 18.4 kB
import { select } from 'd3'; import * as d3 from 'd3'; import { ChartEvent } from '../../types/index.js'; import { createSvg, generateName, getChartColor, PolarShapeType, template, } from '../../utils/index.js'; import { PolarShape } from './index.js'; import { get, isFunction, isNumber } from 'lodash-es'; export const DEFAULT_RADIUS_DIFF = 8; export const ACTIVE_RADIUS_ENLARGE_SIZE = 2; /** * Pie 饼图 环形图 */ export default class Pie extends PolarShape { constructor() { super(...arguments); this.type = PolarShapeType.Pie; this.data = this.getData(); } get nullData() { return this.data.every(d => !isNumber(d.value)); } get totalValue() { return this.data.reduce((prev, item) => prev + item.value, 0); } get colorVar() { return this.ctrl.getTheme().colorVar; } init() { // do nothing. } render() { this.ctrl.container.style.display = 'flex'; this.ctrl.container.style.flexDirection = 'column'; this.option = get(this.ctrl.getOption(), this.type, {}); this.svgEl = this.svgEl || createSvg(select(this.ctrl.container)); this.container = this.container || this.svgEl.append('g'); const legendRef = this.ctrl.components.get('legend'); const { clientHeight } = this.ctrl.container; this.ctrl.emit(ChartEvent.U_PLOT_READY); requestAnimationFrame(() => { const legendEl = this.ctrl.chartContainer.querySelector(`.${generateName('legend')}`); const position = get(this.ctrl.getOption().legend, 'position', ''); const legendH = position.includes('bottom') ? legendEl?.clientHeight : 0; const headerH = this.ctrl.chartContainer.querySelector(`.${generateName('header')}`) ?.clientHeight || 0; const height = clientHeight - legendH - headerH; this.renderPie(height); this.renderLabel(); this.ctrl.on(ChartEvent.LEGEND_ITEM_CLICK, () => { this.data = this.getData().filter(d => !legendRef.inactivatedSet.has(d.name)); this.renderPie(height); this.renderLabel(); }); }); } calculateLabelLength(data) { let labelText = ''; if (!this.option?.labelLine?.labels) { return 0; } const percent = +((data.value / this.totalValue || 0) * 100).toFixed(2); if (this.option.labelLine.labels.includes('name')) { labelText += data.name; } if (this.option.labelLine.labels.includes('value')) { labelText += `${labelText ? ': ' : ''}${data.value}`; } if (this.option.labelLine.labels.includes('percent')) { labelText += ` ${percent}%`; } const formatter = this.option.labelLine.formatter; if (formatter) { labelText = isFunction(formatter) ? formatter(data.name, data.value, percent) : template(formatter, { name: data.name, value: data.value, percent, }); } return labelText.length; } renderPie(clientHeight) { const { clientWidth } = this.svgEl.node(); let radius = Math.min(clientWidth, clientHeight) / 2 - ACTIVE_RADIUS_ENLARGE_SIZE; // 动态调整半径以确保引导线和标签不会超出屏幕 const maxLabelLength = this.calculateLabelLength(this.data); const labelSpace = Math.min(maxLabelLength * 8, clientWidth / 2); // 假设每个字符占8个像素,可以根据实际情况调整 const maxRadius = Math.min(clientWidth / 2 - labelSpace, clientHeight / 2 - (this.option?.labelLine?.labels?.length ? clientHeight / 5 : 10)); // 动态调整高度 radius = Math.min(radius, maxRadius); const paths = calculatePaths(this.data, { ...this.option, outerRadius: radius, backgroundColor: this.nullData ? this.colorVar['n-8'] : this.option?.backgroundColor || this.colorVar['n-8'], }, this.colorVar['n-8']); this.container.selectAll('path').remove(); this.container .attr('transform', `translate(${clientWidth / 2},${clientHeight / 2})`) .selectAll('path') .data(paths) .join('path') .attr('fill', e => e.config.color) .attr('d', e => e.path); this.addListener(); this.renderGuidelines(paths); } getLabel(d) { let labelText = ''; const percent = +((d.data.value / this.totalValue || 0) * 100).toFixed(2); if (this.option.labelLine.labels.includes('name')) labelText += d.data.name; if (this.option.labelLine.labels.includes('value')) labelText += `${labelText ? ': ' : ''}${d.data.value}`; if (this.option.labelLine.labels.includes('percent')) labelText += ` ${percent}%`; if (this.option.labelLine.formatter) { labelText = isFunction(this.option.labelLine.formatter) ? this.option.labelLine.formatter(d.data.name, d.data.value, percent) : template(this.option.labelLine.formatter, { name: d.data.name, value: d.data.value, percent, }); } return labelText; } renderGuidelines(paths) { this.container.selectAll('.guideline-group').remove(); if (!this.option?.labelLine?.show || this.nullData || !this.option?.labelLine?.labels?.length) { return; } const guidelineGroup = this.container .selectAll('.guideline-group') .data([null]); guidelineGroup.enter().append('g').attr('class', 'guideline-group'); // 过滤有效数据项(带data的路径) const validPaths = paths.filter((d) => d.data); const radius = validPaths[0]?.config.outerRadius || 0; const labelRadius = radius + 15; // 参考HTML中的labelRadius逻辑 // 1. 计算初始标签位置 const labels = validPaths.map((d) => { const midAngle = (d.config.startAngle + d.config.endAngle) / 2; const x = Math.cos(midAngle - Math.PI / 2) * labelRadius; const y = Math.sin(midAngle - Math.PI / 2) * labelRadius; const text = this.getLabel(d); // 临时测量文本宽度(使用SVG的text元素) const tempText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); tempText.textContent = text; tempText.setAttribute('font-size', '12px'); const svg = this.svgEl.node(); svg.appendChild(tempText); const textWidth = tempText.getComputedTextLength(); const textHeight = tempText.getBBox().height; svg.removeChild(tempText); return { x, y, angle: midAngle, side: x > 0 ? 'right' : 'left', datum: d, text, textWidth: Math.min(textWidth, 150), textHeight: Math.min(textHeight, 50), // 限制最大高度 }; }); console.log(labels); // 2. 优化力导向模拟(增加x轴力和高度碰撞检测) const simulation = d3 .forceSimulation(labels) .force('y', d3.forceY(d => d.y).strength(0.6)) .force('x', d3.forceX(d => d.x).strength(0.2)) .force('collide', d3.forceCollide((d) => Math.max(d.textWidth / 2, d.textHeight / 2) + 2)) .stop(); for (let i = 0; i < 300; ++i) simulation.tick(); // 新增:约束标签位置,避免进入饼图内部 const minSafeDistance = radius + 15; // 与labelRadius保持一致的最小安全距离 labels.forEach((label) => { const currentDistance = Math.sqrt(label.x ** 2 + label.y ** 2); if (currentDistance < minSafeDistance) { // 按比例将标签位置推送到安全距离外,保持原有方向 const scale = minSafeDistance / currentDistance; label.x *= scale; label.y *= scale; } }); const guidelines = this.container .select('.guideline-group') .selectAll('.line') .data(labels); guidelines .join('polyline') .attr('class', 'line') .attr('fill', 'none') .attr('stroke', (d) => d.datum.config.color) .attr('stroke-width', 1) .attr('points', (d) => { const outerRadius = d.datum.config.outerRadius; const midAngle = d.angle; const adjustedAngle = midAngle - Math.PI / 2; const x1 = Math.cos(adjustedAngle) * outerRadius; const y1 = Math.sin(adjustedAngle) * outerRadius; const x2 = Math.cos(adjustedAngle) * (radius + 10); const y2 = Math.sin(adjustedAngle) * (radius + 10); const x3 = d.side === 'right' ? x2 + 6 : x2 - 6; return [ [x1, y1], [x2, y2], [x3, d.y], ]; }); const labelsGroup = this.container .select('.guideline-group') .selectAll('.label') .data(labels); labelsGroup .join('text') .attr('class', 'label') .attr('x', (d) => { const x2 = Math.cos(d.angle - Math.PI / 2) * (radius + 4); return d.x > 0 ? x2 + 8 : x2 - 8; }) // 参考HTML的12px偏移 .attr('y', (d) => (d.y > 0 ? d.y + 10 : d.y - 2)) .attr('text-anchor', (d) => (d.x > 0 ? 'start' : 'end')) .attr('font-size', '12px') .attr('pointer-events', 'none') .attr('fill', this.colorVar['n-1']) .text((d) => { let labelText = ''; const percent = +((d.datum.data.value / this.totalValue || 0) * 100).toFixed(2); if (this.option.labelLine.labels.includes('name')) labelText += d.datum.data.name; if (this.option.labelLine.labels.includes('value')) labelText += `${labelText ? ': ' : ''}${d.datum.data.value}`; if (this.option.labelLine.labels.includes('percent')) labelText += ` ${percent}%`; if (this.option.labelLine.formatter) { labelText = isFunction(this.option.labelLine.formatter) ? this.option.labelLine.formatter(d.datum.data.name, d.datum.data.value, percent) : template(this.option.labelLine.formatter, { name: d.datum.data.name, value: d.datum.data.value, percent, }); } return labelText; }); } onMousemove(res) { const item = res.data; const path = getPath({ ...item.config, padAngle: item.config.padAngle, innerRadius: item.config.innerRadius, outerRadius: item.config.outerRadius + ACTIVE_RADIUS_ENLARGE_SIZE, borderWidth: item.config.borderWidth - ACTIVE_RADIUS_ENLARGE_SIZE, }); d3.select(res.self) .attr('opacity', 0.9) .transition() .attr('d', path); } onMouseleave(res) { const item = res.data; d3.select(res.self) .attr('opacity', 1) .transition() .attr('d', getPath(item.config)); } addListener() { const pieItems = this.container.selectAll('path').filter(function (e) { return !!e.data; }); const ctrl = this.ctrl; pieItems .on('mouseover', function (event, data) { ctrl.emit(ChartEvent.ELEMENT_MOUSEMOVE, { self: this, event, data, }); if (!ctrl.hideTooltip) { ctrl.emit(ChartEvent.U_PLOT_SET_CURSOR, { anchor: event.target, values: [data.data], }); ctrl.components.get('tooltip').showTooltip(); } }) .on('mouseout', function (event, data) { ctrl.emit(ChartEvent.ELEMENT_MOUSELEAVE, { self: this, event, data, }); if (!ctrl.hideTooltip) { ctrl.components.get('tooltip').hideTooltip(); } }); } renderLabel() { if (this.option.label) { const { x = 0, y = 0 } = this.option.label.position || {}; if (!this.pieGuide) { this.pieGuide = select(this.ctrl.container) .append('div') .style('position', 'absolute'); } if (this.option.label.text) { this.pieGuide.html(this.option.label.text); } this.pieGuide .style('top', `calc(50% + ${x}px`) .style('left', `calc(50% + ${y}px`) .style('transform', 'translate(-50%, -50%)'); } } redraw() { const { clientHeight } = this.ctrl.container; const legendEl = this.ctrl.chartContainer.querySelector(`.${generateName('legend')}`); const position = get(this.ctrl.getOption().legend, 'position', ''); const legendH = position.includes('bottom') ? legendEl?.clientHeight : 0; const headerH = this.ctrl.chartContainer.querySelector(`.${generateName('header')}`) ?.clientHeight || 0; const height = clientHeight - legendH - headerH; this.renderPie(height); this.renderLabel(); } } export function getPath(config) { const arc = d3 .arc() .cornerRadius(config.borderRadius) .padAngle(config.padAngle || (config.borderWidth * Math.PI) / 180); return arc({ innerRadius: config.innerRadius, outerRadius: config.outerRadius, startAngle: config.startAngle, endAngle: config.endAngle, }); } export function calculatePaths(data, option, color) { const rawTotal = data.reduce((acc, curr) => acc + curr.value, 0); const total = Math.max(option.total || 0, rawTotal); const startAngle = (option.startAngle || 0) % (2 * Math.PI); const endAngle = (option.endAngle || 0) % (2 * Math.PI) || 2 * Math.PI; const diffAngle = endAngle < startAngle ? ((endAngle - startAngle) % (2 * Math.PI)) + 2 * Math.PI : endAngle - startAngle; const angles = data.map(data => (data.value / total || 0) * diffAngle); const { outerRadius, innerRadius } = getRadius({ ...option, innerRadius: data.length === 1 && !option.innerRadius ? 0 : option.innerRadius || option.padAngle - 0.01, }); const { borderRadius = 2, borderWidth = 0 } = option?.itemStyle || {}; const arc = d3 .arc() .cornerRadius(borderRadius) .padAngle(data.length === 1 ? 0 : option.padAngle || 0); let accumulate = startAngle; const baseConfig = { padAngle: data.length === 1 || !data?.length ? 0 : option.padAngle || 0, innerRadius, outerRadius, borderRadius, borderWidth, }; const padding = 14; const innerDisc = option.innerDisc ? [ { path: arc({ innerRadius: innerRadius - padding, outerRadius: innerRadius - padding - 4, startAngle, endAngle, }), config: { color, startAngle, endAngle, ...baseConfig, }, }, ] : []; return angles.reduce((acc, curr, ind) => { const startAngle = accumulate; const endAngle = accumulate + angles[ind]; const midAngle = (startAngle + endAngle) / 2; const x1 = Math.cos(midAngle) * outerRadius; const y1 = Math.sin(midAngle) * outerRadius; const x2 = Math.cos(midAngle) * (outerRadius + 10); const y2 = Math.sin(midAngle) * (outerRadius + 10); const x3 = x2 + (midAngle < Math.PI ? 1 : -1) * 50; const polylinePoints = [ [x1, y1], [x2, y2], [x3, y2], ]; const result = [ ...acc, { path: arc({ innerRadius, outerRadius, startAngle, endAngle, }), config: { color: data[ind].color || getChartColor(ind), startAngle, endAngle, ...baseConfig, }, data: data[ind], polylinePoints, // Add polyline points }, ]; accumulate += curr; return result; }, [ { path: arc({ innerRadius: 0, outerRadius, startAngle: option.startAngle || startAngle, endAngle: option.endAngle || endAngle, }), config: { color: option.backgroundColor || 'transparent', ...baseConfig, startAngle: option.startAngle || startAngle, endAngle: option.endAngle || endAngle, }, data: null, }, ...innerDisc, ]); } export function getRadius(option) { let outerRadius = option.outerRadius; let innerRadius = outerRadius * option.innerRadius || 0; if (!outerRadius && !innerRadius) { throw new Error('Either outerRadius or innerRadius is required!'); } if (!innerRadius && innerRadius !== 0) { innerRadius = outerRadius - DEFAULT_RADIUS_DIFF; } if (!outerRadius) { outerRadius = innerRadius + DEFAULT_RADIUS_DIFF; } return { outerRadius: outerRadius, innerRadius, }; } //# sourceMappingURL=pie.js.map