Skip to content
Closed
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
2 changes: 2 additions & 0 deletions packages/f2/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,6 @@ export {
withCandlestick,
CandlestickView,
} from './candlestick';

export { default as Layout, ChartLayoutProps } from './layout';
export { default as Pictorial, PictorialProps } from './pictorial';
134 changes: 134 additions & 0 deletions packages/f2/src/components/layout/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { Component, jsx } from '@antv/f-engine';
import { isString } from '@antv/util';

export interface ChartLayoutProps {
type?: 'horizontal' | 'vertical' | 'grid' | 'circular';
children?: any;
style?: any;
columns?: number; // 列数
gap?: number | [number, number]; // 间距
itemStyle?: any; // 子组件容器的样式
}

export const FunctionComponent = 0;
export const ClassComponent = 1;
export const Shape = 2;

export default class Layout extends Component<ChartLayoutProps> {
getWorkTag(type) {
if (isString(type)) {
return Shape;
}
if (type.prototype && type.prototype.isF2Component) {
return ClassComponent;
}
return FunctionComponent;
}
getLayoutStyle() {
const { type, style } = this.props;
const baseStyle = {
display: 'flex',
...style,
};

switch (type) {
case 'horizontal':
return {
...baseStyle,
flexDirection: 'row',
alignItems: 'center',
};
case 'vertical':
return {
...baseStyle,
flexDirection: 'column',
alignItems: 'center',
};
case 'grid':
return {
...baseStyle,
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'flex-start',
alignItems: 'flex-start',
};
case 'circular':
return {
// ...baseStyle,
};
default:
return baseStyle;
}
}

calculateChildrenLayout() {
const { type, children, columns = 3, gap = 0, itemStyle } = this.props;
const { width: containerWidth, height: containerHeight } = this.layout;
const childArray = Array.isArray(children) ? children : [children];
const [verticalGap, horizontalGap] = Array.isArray(gap) ? gap : [gap, gap];

return childArray.map((child, index) => {
const childStyle = this.context.px2hd(child.props?.style);
let width = childStyle?.width || containerWidth;
let height = childStyle?.height || containerHeight;
let top = 0;
let left = 0;

if (type === 'horizontal') {
// 水平布局:子元素平均分配容器宽度
const totalGapWidth = (childArray.length - 1) * horizontalGap;
const itemWidth = (containerWidth - totalGapWidth) / childArray.length;
width = itemWidth;
left = index * (itemWidth + horizontalGap);
} else if (type === 'vertical') {
// 垂直布局:子元素平均分配容器高度
const totalGapHeight = (childArray.length - 1) * verticalGap;
const itemHeight = (containerHeight - totalGapHeight) / childArray.length;
height = itemHeight;
top = index * (itemHeight + verticalGap);
} else if (type === 'grid') {
// 双向布局:根据列数计算每个子元素的宽度和位置
const row = Math.floor(index / columns);
const col = index % columns;
const totalGapWidth = (columns - 1) * horizontalGap;
const itemWidth = (containerWidth - totalGapWidth) / columns;
width = itemWidth;
left = col * (itemWidth + horizontalGap);
top = row * (height + verticalGap);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

grid 布局的行定位计算方式存在问题。这里的 top 值是基于 height 计算的,而 height 来自子元素自身的样式,如果子元素没有设置高度,则会取容器的高度。这在栅格布局中会导致问题:

  1. 如果子元素没有高度,height 会是 containerHeight,这通常是不对的。
  2. 如果子元素高度不同,下一行的起始位置会基于前一个元素的高度计算,导致同行元素无法对齐。

建议考虑为 grid 布局增加一个 itemHeight 属性,或者动态计算每行的最大高度来确定下一行的偏移量。

} else if (type === 'circular') {
// 圆形布局:将子元素均匀分布在圆周上
const radius = Math.min(containerWidth, containerHeight) / 2;
const angle = (index * 2 * Math.PI) / childArray.length;
left = containerWidth / 2 + radius * Math.cos(angle);
top = containerHeight / 2 + radius * Math.sin(angle);
Comment on lines +102 to +103
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

circular 布局中,子元素是根据其左上角进行定位的。这意味着子元素会以其左上角顶点分布在圆周上。为了视觉上更居中,可以考虑在计算 lefttop 时减去子元素自身宽高的一半。例如 left = ... - childWidth / 2。当然,获取子元素的宽高可能需要一些额外的工作。

}
const tag = this.getWorkTag(child.type);

if (tag === Shape) {
if (type === 'circular') {
return <group style={{ x: left, y: top, ...itemStyle }}>{child}</group>;
}
return child;
}
Comment on lines +107 to +112
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

对于 Shape 类型的子元素,只有在 circular 布局下才应用了定位。在 horizontal, vertical, grid 布局下,计算出的 lefttop 值被忽略了,Shape 元素会直接返回,导致它们堆叠在原点。应该为所有 Shape 元素统一应用定位。

      if (tag === Shape) {
        return <group style={{ x: left, y: top, ...itemStyle }}>{child}</group>;
      }


return (
<group
style={{
width,
height,
...itemStyle,
}}
>
{child}
</group>
Comment on lines +115 to +123
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

lefttop 定位属性没有应用到子组件上。这里计算出的 lefttop 值没有在包裹的 <group> 组件的 style 中使用,导致子组件的位置不正确。

另外,在 getLayoutStyle 方法中使用了 display: 'flex',这与手动计算 left/top 的绝对定位方式存在冲突。建议统一采用一种布局方式。如果采用手动定位,应从 getLayoutStyle 中移除 flex 相关属性,并在这里正确应用 xy 坐标。

        <group
          style={{
            x: left,
            y: top,
            width,
            height,
            ...itemStyle,
          }}
        >
          {child}
        </group>

);
});
}

render() {
const layoutStyle = this.getLayoutStyle();
const childrenWithLayout = this.calculateChildrenLayout();

return <group style={layoutStyle}>{childrenWithLayout}</group>;
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
162 changes: 162 additions & 0 deletions packages/f2/test/components/layout/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { jsx, Component, Canvas, Chart, Line, Legend, Tooltip, TextGuide } from '../../../src';
import { Layout } from '../../../src/components';
import { createContext, delay } from '../../util';
const data1 = [
{ x: 0, y: 1, name: 'A' },
{ x: 1, y: 2, name: 'B' },
{ x: 2, y: 3, name: 'C' },
{ x: 3, y: 4, name: 'D' },
];

const data2 = [
{ x: 0, y: 4, name: 'E' },
{ x: 1, y: 3, name: 'F' },
{ x: 2, y: 2, name: 'G' },
{ x: 3, y: 1, name: 'H' },
];

const ChartA = (props) => {
const { data, color, style } = props;
return (
<Chart data={data} color={color} style={style}>
<Line x="x" y="y" color={color} />
<Legend />
<Tooltip />
<TextGuide content={`textGuide`} records={[data[1]]} />
</Chart>
);
};

describe('布局组件', () => {
describe('布局类型', () => {
it('横向布局', async () => {
const context = createContext('横向布局', {
width: '300px',
height: '100px',
});
const { props } = (
<Canvas context={context} pixelRatio={1}>
<Layout type="horizontal">
<rect style={{ width: '50px', height: '50px', fill: '#FF5733' }} />
<rect style={{ width: '50px', height: '50px', fill: '#33FF57' }} />
<rect style={{ width: '50px', height: '50px', fill: '#3357FF' }} />
</Layout>
</Canvas>
);
const canvas = new Canvas(props);
await canvas.render();

await delay(1000);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

在测试中使用 delay(1000) 等待 1 秒可能没有必要,并且会显著拖慢整个测试套件的执行速度。delay 函数内部已经使用了 setTimeoutrequestAnimationFrame 来确保渲染完成。可以尝试一个更短的延迟,例如 delay(100) 或者更小的值,只要能保证快照正确即可。

Suggested change
await delay(1000);
await delay(100);

expect(context).toMatchImageSnapshot();
});

it('圆形布局', async () => {
const context = createContext('圆形布局', {
width: '300px',
height: '100px',
});
const { props } = (
<Canvas context={context} pixelRatio={1}>
<Layout type="circular">
<circle style={{ r: '10px', fill: '#FF6B6B' }} />
<circle style={{ r: '10px', fill: '#4ECDC4' }} />
<circle style={{ r: '10px', fill: '#45B7D1' }} />
<circle style={{ r: '10px', fill: '#96CEB4' }} />
<circle style={{ r: '10px', fill: '#FFEEAD' }} />
<circle style={{ r: '10px', fill: '#D4A5A5' }} />
</Layout>
</Canvas>
);
const canvas = new Canvas(props);
await canvas.render();

await delay(1000);
expect(context).toMatchImageSnapshot();
});

it('横向布局包含两个图表', async () => {
const context = createContext('横向布局', {
width: '300px',
height: '100px',
});
const { props } = (
<Canvas context={context}>
<Layout
type="horizontal"
itemStyle={{
stroke: '#333333',
lineWidth: 1,
}}
>
<ChartA data={data1} color="#4ECDC4" style={{ height: '120px' }} />
<ChartA data={data2} color="#FF6B6B" style={{ height: '80px' }} />
</Layout>
</Canvas>
);
const canvas = new Canvas(props);
await canvas.render();

await delay(1000);
expect(context).toMatchImageSnapshot();
});

it('纵向布局包含两个图表', async () => {
const context = createContext('纵向布局包含两个图表', {
width: '200px',
height: '200px',
});

const { props } = (
<Canvas context={context}>
<Layout
type="vertical"
itemStyle={{
stroke: '#333333',
lineWidth: 1,
}}
>
<ChartA data={data1} color="#4ECDC4" />
<ChartA data={data2} color="#FF6B6B" />
</Layout>
</Canvas>
);
const canvas = new Canvas(props);
await canvas.render();

await delay(1000);
expect(context).toMatchImageSnapshot();
});

it('Grid布局', async () => {
const context = createContext('Grid布局', {
width: '300px',
height: '300px',
});

const { props } = (
<Canvas context={context}>
<Layout
type="grid"
columns={2}
itemStyle={{
stroke: '#333333',
lineWidth: 1,
}}
>
{/* 第一行 */}
<ChartA data={data1} color="#4ECDC4" style={{ height: '120px' }} />
<ChartA data={data2} color="#FF6B6B" style={{ height: '120px' }} />
{/* 第二行 */}
<ChartA data={data1} color="#96CEB4" style={{ height: '120px' }} />
<ChartA data={data2} color="#FFEEAD" style={{ height: '120px' }} />
</Layout>
</Canvas>
);
const canvas = new Canvas(props);
await canvas.render();

await delay(1000);
expect(context).toMatchImageSnapshot();
});
});
});
Loading