Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions src/shape/interval/funnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,22 @@ 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?: (
points: Vector2[],
nextPoints: Vector2[],
coordinate: Coordinate,
) => Vector2[];
borderRadius?:
| number
| {
topLeft?: number;
topRight?: number;
bottomLeft?: number;
bottomRight?: number;
};
[key: string]: any;
};

Expand Down Expand Up @@ -38,8 +46,9 @@ function getFunnelPoints(
* Render funnel in different coordinate and using color channel for stroke and fill attribute.
*/
export const Funnel: SC<FunnelOptions> = (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;
Expand All @@ -48,10 +57,12 @@ export const Funnel: SC<FunnelOptions> = (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)
Expand Down
249 changes: 249 additions & 0 deletions src/shape/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,252 @@ 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 ||
points[0],
topRight:
identifiedPoints.find((p) => p.position === 'topRight')?.point ||
points[1],
bottomRight:
identifiedPoints.find((p) => p.position === 'bottomRight')?.point ||
points[2],
bottomLeft:
identifiedPoints.find((p) => p.position === 'bottomLeft')?.point ||
points[3],
};
Comment on lines +291 to +298

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The fallback logic for sortedPoints is unsafe and can lead to bugs. It might assign the same point to multiple corners, which would result in a malformed or degenerate shape. For example, if no point is identified as topLeft, the logic falls back to using points[0]. However, points[0] might have already been identified and assigned to another corner (e.g., topRight), causing two corners of the shape to share the same coordinate.


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 any)?.[corner] || 0;
};
Comment on lines +402 to +410

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The use of as any bypasses TypeScript's type safety and should be avoided. It can hide potential bugs and makes the code harder to maintain.

Suggested change
const getRadius = (corner: string) => {
if (typeof borderRadius === 'number') {
return borderRadius;
}
return (borderRadius as any)?.[corner] || 0;
};
const getRadius = (corner: 'topLeft' | 'topRight' | 'bottomRight' | 'bottomLeft') => {
if (typeof borderRadius === 'number') {
return borderRadius;
}
return borderRadius?.[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'];
const cornerNames = ['topLeft', 'topRight', 'bottomRight', 'bottomLeft'];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The type casts as keyof typeof radii can be avoided by providing a more specific type for cornerNames. This improves type safety and makes the code easier to read and maintain.

Suggested change
const cornerNames = ['topLeft', 'topRight', 'bottomRight', 'bottomLeft'];
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;
}
Comment on lines +242 to +497

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The new functions and interfaces in this file have comments written in Chinese. For consistency with the rest of the codebase, which is in English, and to improve maintainability for all contributors, please translate these comments to English.

Examples:

  • Line 243: * 表示一条边
  • Line 253: * 根据坐标自动识别四边形的顶点位置,无需考虑坐标的顺序
  • Line 336: isStartCorner: boolean, // true表示在边的起点,false表示在边的终点