Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
259 changes: 258 additions & 1 deletion src/shape/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
}
Loading