UNPKG

zz-chart

Version:

Alauda Chart components by Alauda Frontend Team

684 lines 27.4 kB
import { select } from 'd3'; import * as d3 from 'd3'; import { get, isFunction, isNumber } from 'lodash-es'; import { ChartEvent } from '../../types/index.js'; import { createSvg, generateName, PolarShapeType, template, } from '../../utils/index.js'; import { PolarShape } from './index.js'; export const DEFAULT_RADIUS_DIFF = 8; export const ACTIVE_RADIUS_ENLARGE_SIZE = 2; const LAYOUT_CONFIG = { anchorOffset: 5, elbowRadiusScale: 1.2, lineLength: 12, labelHeight: 12, paddingY: 2, // 标签之间的最小垂直间距 }; /** * 辅助函数:将极坐标 (D3 角度体系) 转换为笛卡尔坐标 (x, y) * D3 Arc: 0 在 12 点钟方向, 顺时针增加 */ function polarToCartesian(radius, angle) { return [radius * Math.sin(angle), -radius * Math.cos(angle)]; } class LabelGroup { constructor(item) { this.items = []; this.items.push(item); } merge(other) { this.items = this.items.concat(other.items); this.items.sort((a, b) => a.idealP1[1] - b.idealP1[1]); } overlaps(other, spacing) { const thisBottom = this.getBoundary(spacing).bottom; const otherTop = other.getBoundary(spacing).top; return thisBottom > otherTop; } getBoundary(spacing) { const totalHeight = this.items.length * spacing; const avgIdealY = this.items.reduce((sum, item) => sum + item.idealP1[1], 0) / this.items.length; const top = avgIdealY - totalHeight / 2; return { top, bottom: top + totalHeight }; } } /** * 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(); }); }); } /** * 生成标签文本 */ getLabelText(data) { const percent = +((data.value / this.totalValue || 0) * 100).toFixed(2); const { labels, formatter } = this.option.labelLine || {}; let labelText = ''; // 默认拼接逻辑 if (labels && Array.isArray(labels)) { const parts = []; if (labels.includes('name')) parts.push(data.name); if (labels.includes('value')) parts.push(`${parts.length ? ': ' : ''}${data.value}`); if (labels.includes('percent')) parts.push(` ${percent}%`); labelText = parts.join(''); } // 自定义 formatter 覆盖 if (formatter) { labelText = isFunction(formatter) ? formatter(data, percent) : template(formatter, { data, percent }); } return labelText; } /** * 估算标签最大长度用于计算半径 */ calculateLabelLength(data) { if (!this.option?.labelLine?.labels) return 0; return this.getLabelText(data).length; } renderPie(clientHeight) { const { clientWidth } = this.svgEl.node(); // 1. 基础半径:容器宽高的一半,减去 hover 放大所需的 buffer (2px) let radius = Math.min(clientWidth, clientHeight) / 2 - ACTIVE_RADIUS_ENLARGE_SIZE; // 只有当需要显示引导线时,才计算复杂的避让半径 if (this.option.labelLine?.show) { let maxLabelCharLength = 0; this.data.forEach(d => { const len = this.calculateLabelLength(d); if (len > maxLabelCharLength) maxLabelCharLength = len; }); const maxAllowedLabelWidth = clientWidth * 0.4; const estimatedLabelWidth = maxLabelCharLength * 12 + LAYOUT_CONFIG.lineLength + 10; const finalLabelWidth = Math.min(estimatedLabelWidth, maxAllowedLabelWidth); const maxRadiusHorizontal = clientWidth / 2 - finalLabelWidth; const verticalPadding = 20; const maxRadiusVertical = (clientHeight / 2 - verticalPadding) / LAYOUT_CONFIG.elbowRadiusScale; const maxRadius = Math.min(maxRadiusHorizontal, maxRadiusVertical, clientHeight / 2 - 10); radius = Math.min(radius, maxRadius); } const minRadius = Math.min(clientWidth, clientHeight) * 0.15; radius = Math.max(radius, minRadius); const rawPaths = 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.ctrl); const paths = rawPaths.map((p, i) => ({ ...p, originalIndex: i, })); 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); } /** * 重心平衡布局 + 可见性过滤 */ calculateLabelLayout(paths) { const validPaths = paths .map((d, i) => ({ ...d, originalIndex: i })) .filter(d => d.data && d.data.value > 0); if (validPaths.length === 0) return []; const outerRadius = validPaths[0].config.outerRadius; const r0 = outerRadius + LAYOUT_CONFIG.anchorOffset; const r1 = outerRadius * LAYOUT_CONFIG.elbowRadiusScale; const spacing = LAYOUT_CONFIG.labelHeight + LAYOUT_CONFIG.paddingY; let allLabels = validPaths.map((d) => { const midAngle = (d.config.startAngle + d.config.endAngle) / 2; const normalizedAngle = midAngle % (Math.PI * 2); const isRightSide = normalizedAngle >= 0 && normalizedAngle < Math.PI; return { data: d, p0: polarToCartesian(r0, midAngle), idealP1: polarToCartesian(r1, midAngle), finalP1: [0, 0], isRightSide: isRightSide, color: d.config.color, labelText: this.getLabelText(d.data), originalIndex: d.originalIndex, }; }); const leftLabels = allLabels .filter(d => !d.isRightSide) .sort((a, b) => a.idealP1[1] - b.idealP1[1]); const rightLabels = allLabels .filter(d => d.isRightSide) .sort((a, b) => a.idealP1[1] - b.idealP1[1]); const processSide = (labels, isRight) => { if (labels.length === 0) return []; let groups = labels.map(l => new LabelGroup(l)); let hasOverlap = true; while (hasOverlap) { hasOverlap = false; for (let i = 0; i < groups.length - 1; i++) { if (groups[i].overlaps(groups[i + 1], spacing)) { groups[i].merge(groups[i + 1]); groups.splice(i + 1, 1); hasOverlap = true; break; } } } let results = []; let lastVisibleY = -Infinity; const { clientHeight } = this.ctrl.container; const safeMaxY = clientHeight / 2 - 40; const totalAvailableHeight = safeMaxY * 2; groups.forEach(group => { let currentSpacing = spacing; const itemCount = group.items.length; const totalNeededHeight = itemCount * spacing; if (totalNeededHeight > totalAvailableHeight) { const compressedSpacing = totalAvailableHeight / itemCount; // 设置最小间距极限,防止文字重叠 const minSpacing = LAYOUT_CONFIG.labelHeight * 0.85; currentSpacing = Math.max(compressedSpacing, minSpacing); } const groupHeight = itemCount * currentSpacing; const boundary = group.getBoundary(spacing); let startY = boundary.top; if (startY < -safeMaxY) startY = -safeMaxY; if (startY + groupHeight > safeMaxY) { startY = safeMaxY - groupHeight; if (startY < -safeMaxY) startY = -safeMaxY; } if (startY < lastVisibleY + LAYOUT_CONFIG.paddingY) { startY = lastVisibleY + LAYOUT_CONFIG.paddingY; } let currentY = startY + currentSpacing / 2; group.items.forEach(item => { let visible = true; if (Math.abs(currentY) > safeMaxY) visible = false; let finalY = currentY; if (visible) { lastVisibleY = finalY + LAYOUT_CONFIG.labelHeight / 2; } const p0 = item.p0; // 获取锚点 P0 let xAbs = 0; if (Math.abs(finalY) < r1 - 0.001) { xAbs = Math.sqrt(r1 * r1 - finalY * finalY); } const minSafeXAbs = Math.abs(p0[0]) * 1.02; xAbs = Math.max(xAbs, minSafeXAbs); const p1 = [isRight ? xAbs : -xAbs, finalY]; const p2 = [ p1[0] + (isRight ? LAYOUT_CONFIG.lineLength : -LAYOUT_CONFIG.lineLength), p1[1], ]; results.push({ startPoint: item.p0, elbowPoint: p1, endPoint: p2, textX: p2[0] + (isRight ? 5 : -5), textY: p2[1], textAlign: isRight ? 'start' : 'end', labelText: item.labelText, color: item.color, data: item.data.data, index: item.originalIndex, visible: visible, }); currentY += currentSpacing; }); }); return results; }; const leftResults = processSide(leftLabels, false); const rightResults = processSide(rightLabels, true); return [...leftResults, ...rightResults]; } /** * 渲染引导线:增加 class 和 初始 opacity */ renderGuidelines(paths) { this.container.selectAll('.guideline-group').remove(); if (!this.option?.labelLine?.show || this.nullData) return; const { clientWidth } = this.svgEl.node(); const layoutData = this.calculateLabelLayout(paths); const guidelineGroup = this.container .selectAll('.guideline-group') .data([null]) .join('g') .attr('class', 'guideline-group'); // 绘制折线 (保持不变) guidelineGroup .selectAll('.guideline') .data(layoutData) .join('path') .attr('class', d => `guideline label-item-${d.index}`) .attr('fill', 'none') .attr('stroke', d => d.color) .attr('d', d => `M${d.startPoint[0]},${d.startPoint[1]}L${d.elbowPoint[0]},${d.elbowPoint[1]}L${d.endPoint[0]},${d.endPoint[1]}`) .attr('opacity', d => (d.visible ? 1 : 0)) .style('pointer-events', 'none'); guidelineGroup .selectAll('.label') .data(layoutData) .join('text') .attr('class', d => `label label-item-${d.index}`) .attr('x', d => d.textX) .attr('y', d => d.textY) .attr('dy', '0.35em') .attr('text-anchor', d => d.textAlign) .style('paint-order', 'stroke fill') // 关键:描边在底部,填充在顶部 .style('stroke', '#fff') // 必须与背景色一致 .style('stroke-width', '5px') // 加粗!3px可能盖不住,5px效果更好 .style('stroke-linecap', 'butt') .style('stroke-linejoin', 'miter') .text(d => { const absX = Math.abs(d.textX); const halfWidth = clientWidth / 2; const maxTextWidth = halfWidth - absX - 10; return truncateText(d.labelText, Math.max(0, maxTextWidth)); }) .attr('opacity', d => (d.visible ? 1 : 0)) .style('font-size', '12px') .style('pointer-events', 'none'); // 避免文字遮挡鼠标事件 } /** * 交互事件监听:增加 Hover 联动 */ addListener() { const pieItems = this.container.selectAll('path').filter(function (d) { return !!d.data; }); const ctrl = this.ctrl; const container = this.container; const tooltip = ctrl.components.get('tooltip'); pieItems .on('mouseover', function (event, data) { ctrl.emit(ChartEvent.ELEMENT_MOUSEMOVE, { self: this, event, data }); if (!ctrl.hideTooltip) { const tooltipOption = tooltip.option; const mode = get(tooltipOption, 'mode') || 'single'; // 默认为 single let valuesToEmit = []; if (mode === 'all') { valuesToEmit = data.source; } else { valuesToEmit = [data.data]; } ctrl.emit(ChartEvent.U_PLOT_SET_CURSOR, { anchor: event.target, values: valuesToEmit, position: 'top', }); ctrl.components.get('tooltip').showTooltip(); } d3.select(this).attr('opacity', 0.9); const currentIndex = data.originalIndex; const currentGroup = container.selectAll(`.label-item-${currentIndex}`); currentGroup.attr('opacity', 1).style('font-weight', 'bold').raise(); }) .on('mouseover.highlight', (_, d) => { container .selectAll('.guideline, .label') .attr('opacity', (labelData) => { if (labelData.data === d.data) return 1; return labelData.visible ? 1 : 0; }) .style('font-weight', (labelData) => { return labelData.data === d.data ? 'bold' : 'normal'; }); container .selectAll(`.label-item-${d.originalIndex || ''}`) .raise(); }) .on('mouseout', function (event, data) { d3.select(this).attr('opacity', 1); container .selectAll('.guideline, .label') .attr('opacity', (labelData) => (labelData.visible ? 1 : 0)) .style('font-weight', 'normal'); ctrl.emit(ChartEvent.ELEMENT_MOUSELEAVE, { self: this, event, data, }); if (!ctrl.hideTooltip) { ctrl.components.get('tooltip').hideTooltip(); } }); } onMousemove(res) { const item = res.data; const sel = d3.select(res.self); const config = item.config; const newOuterRadius = config.outerRadius + ACTIVE_RADIUS_ENLARGE_SIZE; const originalBorderWidth = config.borderWidth || 0; const newBorderWidth = Math.max(0, originalBorderWidth - ACTIVE_RADIUS_ENLARGE_SIZE); const thickness = newOuterRadius - config.innerRadius; const safeBorderRadius = Math.min(config.borderRadius || 0, thickness / 2); const path = getPath({ ...config, outerRadius: newOuterRadius, borderWidth: newBorderWidth, borderRadius: safeBorderRadius, padAngle: config.padAngle, }); sel.interrupt(); sel.attr('opacity', 0.9).attr('d', path); } onMouseleave(res) { const item = res.data; const originalConfig = item.config; const sel = d3.select(res.self); // 获取当前状态(即放大后的状态)作为动画起点 // 注意:这里手动构建放大的 config,确保和 mousemove/mouseover 里的逻辑一致 const enlargedConfig = { ...originalConfig, outerRadius: originalConfig.outerRadius + ACTIVE_RADIUS_ENLARGE_SIZE, // 注意:这里需要重新计算 mousemove 时使用的 borderWidth 和 borderRadius // 否则插值起点会跳变 borderWidth: Math.max(0, (originalConfig.borderWidth || 0) - ACTIVE_RADIUS_ENLARGE_SIZE), // 这里的 radius 不重要,因为 getPath 会自动 clamp, // 但为了平滑,最好给一个稍微大一点的值或保持原值 borderRadius: originalConfig.borderRadius, }; sel .interrupt() // 必不可少 .attr('opacity', 1) .transition() .duration(200) .ease(d3.easeQuadOut) .attrTween('d', function () { const interpolator = d3.interpolate(enlargedConfig, originalConfig); return function (t) { return getPath(interpolator(t)); }; }); } 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() { } } export function getPath(config) { let userPadAngle = config.padAngle ?? 0; if (userPadAngle > 1 && config.outerRadius > 0) { userPadAngle = userPadAngle / config.outerRadius; } const fallbackPadAngle = config.borderWidth ? (config.borderWidth * Math.PI) / 180 : 0; const finalPadAngle = config.padAngle !== undefined ? userPadAngle : fallbackPadAngle; const arc = d3 .arc() .cornerRadius(config.borderRadius) .padAngle(finalPadAngle); return arc({ innerRadius: config.innerRadius, outerRadius: config.outerRadius, startAngle: config.startAngle, endAngle: config.endAngle, }); } export function calculatePaths(data, option, color, ctrl) { 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; let 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, }); // ================= padAngle 自适应计算 ================= let finalPadAngle = 0; if (data.length > 1) { const userPad = option.padAngle; if (isNumber(userPad)) { if (userPad > 1) { finalPadAngle = userPad / outerRadius; } else { finalPadAngle = userPad; } } } const { borderRadius = 2, borderWidth = 0 } = option?.itemStyle || {}; const arc = d3.arc().cornerRadius(borderRadius).padAngle(finalPadAngle); let accumulate = startAngle; const baseConfig = { padAngle: data.length === 1 || !data?.length ? 0 : option.padAngle || 0, innerRadius, outerRadius, borderRadius, borderWidth, }; // ================== 默认最小角度处理 ================== const DEFAULT_MIN_DEGREE = 8; const minRadian = (DEFAULT_MIN_DEGREE * Math.PI) / 180; const smallIndices = angles .map((a, i) => (a > 0 && a < minRadian ? i : -1)) .filter(i => i !== -1); if (smallIndices.length > 0 && smallIndices.length * minRadian < diffAngle) { const largeIndices = angles .map((a, i) => (a >= minRadian ? i : -1)) .filter(i => i !== -1); const totalLargeAngle = largeIndices.reduce((sum, i) => sum + angles[i], 0); const totalMinNeeded = smallIndices.length * minRadian; const remainingSpace = diffAngle - totalMinNeeded; if (totalLargeAngle > 0) { const scale = remainingSpace / totalLargeAngle; const newAngles = [...angles]; smallIndices.forEach(i => { newAngles[i] = minRadian; }); largeIndices.forEach(i => { newAngles[i] = angles[i] * scale; }); angles = newAngles; } } // ================== 内环处理 ================== let innerDiscItems = []; if (option.innerDisc) { const gap = getAdaptiveSize(0.1, innerRadius, 0.1); let width = getAdaptiveSize(0.05, innerRadius, 0.04); // 保证线条不要太细看不清 width = Math.max(width, 1.5); // 计算半径 const discOuterRadius = Math.max(0, innerRadius - gap); const discInnerRadius = Math.max(0, discOuterRadius - width); if (discOuterRadius > 0 && discInnerRadius >= 0) { innerDiscItems.push({ path: arc({ innerRadius: discInnerRadius, outerRadius: discOuterRadius, 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 || ctrl.color.getChartColor(data[ind].name), startAngle, endAngle, ...baseConfig, }, data: data[ind], source: data, polylinePoints, }, ]; accumulate += curr; return result; }, [ { path: arc({ innerRadius: option.backgroundArc ? 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, }, ...innerDiscItems, ]); } export function getRadius(option) { let outerRadius = option.outerRadius; let innerRadius = outerRadius * option.innerRadius || 0; if (!innerRadius && innerRadius !== 0) { innerRadius = outerRadius - DEFAULT_RADIUS_DIFF; } if (!outerRadius) { outerRadius = innerRadius + DEFAULT_RADIUS_DIFF; } return { outerRadius: outerRadius, innerRadius, }; } /** * 文本截断辅助函数 * @param text 原始文本 * @param maxWidth 最大允许像素宽度 * @param fontSize 字体大小 (默认12px) */ function truncateText(text, maxWidth, fontSize = 12) { const charWidth = fontSize; let currentWidth = 0; let result = ''; for (let i = 0; i < text.length; i++) { const char = text[i]; // 粗略判断字符宽度 currentWidth += char.charCodeAt(0) > 255 ? charWidth : charWidth * 0.6; if (currentWidth > maxWidth - charWidth * 1.5) { // 预留 ... 的空间 return result + '...'; } result += char; } return text; } function getAdaptiveSize(val, base, defaultRatio) { if (typeof val === 'string' && val.includes('%')) { return (parseFloat(val) / 100) * base; } if (isNumber(val)) { return val <= 1 && val > 0 ? val * base : val; } return base * defaultRatio; } //# sourceMappingURL=pie.js.map