zz-chart
Version:
Alauda Chart components by Alauda Frontend Team
464 lines • 18.4 kB
JavaScript
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