diff --git a/src/shape/interval/funnel.ts b/src/shape/interval/funnel.ts index 95ea8b83b92..bc6e864a0d5 100644 --- a/src/shape/interval/funnel.ts +++ b/src/shape/interval/funnel.ts @@ -3,7 +3,7 @@ import { Coordinate } from '@antv/coord'; import { isTranspose } from '../../utils/coordinate'; import { ShapeComponent as SC, Vector2 } from '../../runtime'; import { select } from '../../utils/selection'; -import { applyStyle, reorder } from '../utils'; +import { applyStyle, createEdgeBasedRoundedPath, reorder } from '../utils'; export type FunnelOptions = { adjustPoints?: ( @@ -11,6 +11,14 @@ export type FunnelOptions = { nextPoints: Vector2[], coordinate: Coordinate, ) => Vector2[]; + borderRadius?: + | number + | { + topLeft?: number; + topRight?: number; + bottomLeft?: number; + bottomRight?: number; + }; [key: string]: any; }; @@ -38,8 +46,9 @@ function getFunnelPoints( * Render funnel in different coordinate and using color channel for stroke and fill attribute. */ export const Funnel: SC = (options, context) => { - const { adjustPoints = getFunnelPoints, ...style } = options; + const { adjustPoints = getFunnelPoints, borderRadius, ...style } = options; const { coordinate, document } = context; + return (points, value, defaults, point2d) => { const { index } = value; const { color: defaultColor, ...rest } = defaults; @@ -48,10 +57,12 @@ export const Funnel: SC = (options, context) => { const tpShape = !!isTranspose(coordinate); const [p0, p1, p2, p3] = tpShape ? reorder(funnelPoints) : funnelPoints; const { color = defaultColor, opacity } = value; - const b = line().curve(curveLinearClosed)([p0, p1, p2, p3]); + const pathData = borderRadius + ? createEdgeBasedRoundedPath([p0, p1, p2, p3], borderRadius || 0) + : line().curve(curveLinearClosed)([p0, p1, p2, p3]); return select(document.createElement('path', {})) .call(applyStyle, rest) - .style('d', b) + .style('d', pathData) .style('fill', color) .style('fillOpacity', opacity) .call(applyStyle, style) diff --git a/src/shape/utils.ts b/src/shape/utils.ts index 81075467396..31c6dfe2c9c 100644 --- a/src/shape/utils.ts +++ b/src/shape/utils.ts @@ -6,7 +6,7 @@ import { Path as D3Path } from '@antv/vendor/d3-path'; import { Primitive, Vector2, Vector3 } from '../runtime'; import { indexOf } from '../utils/array'; import { isPolar, isTranspose } from '../utils/coordinate'; -import { G2Element, Selection } from '../utils/selection'; +import { Selection } from '../utils/selection'; import { angle, angleWithQuadrant, dist, sub } from '../utils/vector'; export function applyStyle( @@ -238,3 +238,260 @@ export function getOrigin(points: (Vector2 | Vector3)[]) { const [[x0, y0, z0 = 0], [x2, y2, z2 = 0]] = points; return [(x0 + x2) / 2, (y0 + y2) / 2, (z0 + z2) / 2]; } + +/** + * 表示一条边 + */ +interface Edge { + start: Vector2; + end: Vector2; + direction: Vector2; // 单位方向向量 + length: number; +} + +/** + * 根据坐标自动识别四边形的顶点位置,无需考虑坐标的顺序 + */ +export function identifyVertices(points: Vector2[]) { + const xs = points.map((p) => p[0]); + const ys = points.map((p) => p[1]); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + + const identifiedPoints = points.map((point, index) => { + const [x, y] = point; + const distToTopLeft = Math.sqrt((x - minX) ** 2 + (y - minY) ** 2); + const distToTopRight = Math.sqrt((x - maxX) ** 2 + (y - minY) ** 2); + const distToBottomRight = Math.sqrt((x - maxX) ** 2 + (y - maxY) ** 2); + const distToBottomLeft = Math.sqrt((x - minX) ** 2 + (y - maxY) ** 2); + + const distances = { + topLeft: distToTopLeft, + topRight: distToTopRight, + bottomRight: distToBottomRight, + bottomLeft: distToBottomLeft, + }; + + const closestCorner = Object.keys(distances).reduce((a, b) => + distances[a as keyof typeof distances] < + distances[b as keyof typeof distances] + ? a + : b, + ) as keyof typeof distances; + + return { + point, + originalIndex: index, + position: closestCorner, + }; + }); + + const sortedPoints = { + topLeft: identifiedPoints.find((p) => p.position === 'topLeft')?.point, + topRight: identifiedPoints.find((p) => p.position === 'topRight')?.point, + bottomRight: identifiedPoints.find((p) => p.position === 'bottomRight') + ?.point, + bottomLeft: identifiedPoints.find((p) => p.position === 'bottomLeft') + ?.point, + }; + + return { + topLeft: sortedPoints.topLeft, + topRight: sortedPoints.topRight, + bottomRight: sortedPoints.bottomRight, + bottomLeft: sortedPoints.bottomLeft, + }; +} + +/** + * 创建边对象 + */ +function createEdge(start: Vector2, end: Vector2): Edge { + const dx = end[0] - start[0]; + const dy = end[1] - start[1]; + const length = Math.sqrt(dx * dx + dy * dy); + + return { + start, + end, + direction: length > 0 ? [dx / length, dy / length] : [0, 0], + length, + }; +} + +/** + * 计算边上的圆角信息 + */ +function calculateEdgeCorner( + edge: Edge, + radius: number, + isStartCorner: boolean, // true表示在边的起点,false表示在边的终点 +): { + cornerPoint: Vector2; // 圆角在边上的位置 + hasRadius: boolean; + actualRadius: number; +} { + if (radius <= 0) { + return { + cornerPoint: isStartCorner ? edge.start : edge.end, + hasRadius: false, + actualRadius: 0, + }; + } + + // 限制圆角半径不超过边长的一半 + const maxRadius = edge.length / 2; + const actualRadius = Math.min(radius, maxRadius); + + if (actualRadius <= 0) { + return { + cornerPoint: isStartCorner ? edge.start : edge.end, + hasRadius: false, + actualRadius: 0, + }; + } + + // 计算圆角在边上的位置 + let cornerPoint: Vector2; + if (isStartCorner) { + // 从起点沿边方向移动radius距离 + cornerPoint = [ + edge.start[0] + edge.direction[0] * actualRadius, + edge.start[1] + edge.direction[1] * actualRadius, + ]; + } else { + // 从终点沿边反方向移动radius距离 + cornerPoint = [ + edge.end[0] - edge.direction[0] * actualRadius, + edge.end[1] - edge.direction[1] * actualRadius, + ]; + } + + return { + cornerPoint, + hasRadius: true, + actualRadius, + }; +} +/** + * 生成基于边的圆角路径 + */ +export function createEdgeBasedRoundedPath( + points: Vector2[], + borderRadius: + | number + | { + topLeft?: number; + topRight?: number; + bottomLeft?: number; + bottomRight?: number; + }, +): string { + // 1. 识别顶点位置 + const vertices = identifyVertices(points); + + // 2. 获取圆角配置 + const getRadius = (corner: string) => { + if (typeof borderRadius === 'number') { + return borderRadius; + } + return ( + ( + borderRadius as { + topLeft?: number; + topRight?: number; + bottomLeft?: number; + bottomRight?: number; + } + )?.[corner] || 0 + ); + }; + + const radii = { + topLeft: getRadius('topLeft'), + topRight: getRadius('topRight'), + bottomRight: getRadius('bottomRight'), + bottomLeft: getRadius('bottomLeft'), + }; + + // 3. 如果所有圆角都为0,返回简单路径 + if (Object.values(radii).every((r) => r === 0)) { + const { topLeft, topRight, bottomRight, bottomLeft } = vertices; + return `M ${topLeft[0]} ${topLeft[1]} L ${topRight[0]} ${topRight[1]} L ${bottomRight[0]} ${bottomRight[1]} L ${bottomLeft[0]} ${bottomLeft[1]} Z`; + } + + // 4. 创建四条边 + const edges = [ + createEdge(vertices.topLeft, vertices.topRight), // 上边 + createEdge(vertices.topRight, vertices.bottomRight), // 右边 + createEdge(vertices.bottomRight, vertices.bottomLeft), // 下边 + createEdge(vertices.bottomLeft, vertices.topLeft), // 左边 + ]; + + const edgeNames = ['top', 'right', 'bottom', 'left'] as const; + const cornerNames = [ + 'topLeft', + 'topRight', + 'bottomRight', + 'bottomLeft', + ] as const; + + // 5. 计算每条边上的圆角点 + const edgeCorners = edges.map((edge, edgeIndex) => { + const startCornerName = cornerNames[edgeIndex]; // 边起点对应的角 + const endCornerName = cornerNames[(edgeIndex + 1) % 4]; // 边终点对应的角 + + const startRadius = radii[startCornerName as keyof typeof radii]; + const endRadius = radii[endCornerName as keyof typeof radii]; + + return { + edge, + edgeName: edgeNames[edgeIndex], + startCorner: calculateEdgeCorner(edge, startRadius, true), // 边起点的圆角 + endCorner: calculateEdgeCorner(edge, endRadius, false), // 边终点的圆角 + startCornerName, + endCornerName, + }; + }); + + // 6. 生成SVG路径 + const pathCommands: string[] = []; + + // 从第一条边的起点圆角开始 + const firstEdge = edgeCorners[0]; + pathCommands.push( + `M ${firstEdge.startCorner.cornerPoint[0]} ${firstEdge.startCorner.cornerPoint[1]}`, + ); + + for (let i = 0; i < 4; i++) { + const currentEdge = edgeCorners[i]; + const nextEdge = edgeCorners[(i + 1) % 4]; + + // 沿着当前边绘制到终点圆角 + pathCommands.push( + `L ${currentEdge.endCorner.cornerPoint[0]} ${currentEdge.endCorner.cornerPoint[1]}`, + ); + + // 在角点处绘制圆角弧线 + const cornerVertex = edges[i].end; // 当前边的终点就是角点 + const hasCornerRadius = currentEdge.endCorner.hasRadius; + + if (hasCornerRadius) { + // 绘制圆角弧线:从当前边的终点圆角到下一条边的起点圆角 + const controlPoint = cornerVertex; // 使用角点作为控制点 + pathCommands.push( + `Q ${controlPoint[0]} ${controlPoint[1]} ${nextEdge.startCorner.cornerPoint[0]} ${nextEdge.startCorner.cornerPoint[1]}`, + ); + } else { + // 没有圆角,直接连到下一条边的起点 + pathCommands.push( + `L ${nextEdge.startCorner.cornerPoint[0]} ${nextEdge.startCorner.cornerPoint[1]}`, + ); + } + } + pathCommands.push('Z'); + const finalPath = pathCommands.join(' '); + return finalPath; +}