From 803ffa6269c9a886bd0eec768b93ace0462da242 Mon Sep 17 00:00:00 2001 From: g1f9 Date: Tue, 14 Jul 2026 18:46:49 +0800 Subject: [PATCH] feat(legend): support pager.hugContent to hug and center paged legend content By default the discrete legend pins its pager to the far end of the available legend space while items stay at the start, so a paged legend always renders justified (items left, pager right) and its bounds always occupy the full available width/height. Add an opt-in pager.hugContent option (default false, fully backward compatible). When enabled: 1. the pager is placed right after the last legend item (with pager.space as the gap), falling back to the original edge-pinned position when the content fills the available space; 2. the paging clip viewport shrinks to the actual content extent, so the legend bounds hug "items + pager" instead of being pinned at the full available size by the fixed-size clip group. With the bounds hugging the content, outer layouts (e.g. VChart legend position 'middle') can center the whole block, matching the common "content + pager centered together" legend layout. --- .../legend-discrete-pager-hug-content.test.ts | 88 +++++++++++++++++++ .../src/legend/discrete/discrete.ts | 32 ++++++- .../src/legend/discrete/type.ts | 8 ++ 3 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 packages/vrender-components/__tests__/unit/legend-discrete-pager-hug-content.test.ts diff --git a/packages/vrender-components/__tests__/unit/legend-discrete-pager-hug-content.test.ts b/packages/vrender-components/__tests__/unit/legend-discrete-pager-hug-content.test.ts new file mode 100644 index 000000000..2bf688206 --- /dev/null +++ b/packages/vrender-components/__tests__/unit/legend-discrete-pager-hug-content.test.ts @@ -0,0 +1,88 @@ +import type { IGraphic, Stage } from '@visactor/vrender-core'; +import { DiscreteLegend } from '../../src'; +import { createCanvas } from '../util/dom'; +import { createTestStage } from '../util/vrender'; + +describe('DiscreteLegend pager.hugContent', () => { + let stage: Stage; + beforeAll(() => { + createCanvas(document.body, 'main'); + stage = createTestStage('main'); + }); + + afterAll(() => { + stage.release(); + }); + + it('horizontal pager should hug content and shrink clip viewport when pager.hugContent is true', () => { + const items = new Array(6).fill(0).map((_, i) => ({ + label: `long-legend-item-label-${i}`, + shape: { fill: 'red', symbolType: 'circle' } + })); + const createLegend = (pager?: any) => + new DiscreteLegend({ + layout: 'horizontal', + maxWidth: 400, + maxRow: 1, + items, + pager + }); + + const defaultLegend = createLegend(); + stage.defaultLayer.add(defaultLegend as unknown as IGraphic); + stage.render(); + const hugLegend = createLegend({ hugContent: true, space: 10 }); + stage.defaultLayer.add(hugLegend as unknown as IGraphic); + stage.render(); + + // 默认行为不变:翻页器钉在图例可用空间末端 + const defaultPager = (defaultLegend as any)._pagerComponent; + expect(defaultPager.attribute.x).toBeCloseTo(400 - defaultPager.AABBBounds.width(), 4); + + // hugContent:翻页器紧跟内容右缘(间距为 pager.space),分页视口收缩到实际内容宽 + const hugPager = (hugLegend as any)._pagerComponent; + const contentWidth = (hugLegend as any)._itemsContainer.AABBBounds.width(); + expect(contentWidth).toBeLessThan(400 - hugPager.AABBBounds.width() - 10); + expect(hugPager.attribute.x).toBeCloseTo(contentWidth + 10, 4); + expect((hugLegend as any)._itemContext.clipContainer.attribute.width).toBeCloseTo(contentWidth, 4); + + // 整体包围盒随内容收缩 + expect(hugLegend.AABBBounds.width()).toBeLessThan(defaultLegend.AABBBounds.width()); + }); + + it('vertical pager should hug content and shrink clip viewport when pager.hugContent is true', () => { + const items = new Array(8).fill(0).map((_, i) => ({ + label: `item-${i}`, + shape: { fill: 'red', symbolType: 'circle' } + })); + const createLegend = (pager?: any) => + new DiscreteLegend({ + layout: 'vertical', + maxHeight: 100, + maxCol: 1, + items, + pager + }); + + const defaultLegend = createLegend(); + stage.defaultLayer.add(defaultLegend as unknown as IGraphic); + stage.render(); + const hugLegend = createLegend({ hugContent: true, space: 10 }); + stage.defaultLayer.add(hugLegend as unknown as IGraphic); + stage.render(); + + // 默认行为不变:翻页器钉在图例可用空间末端 + const defaultPager = (defaultLegend as any)._pagerComponent; + const defaultMaxHeight = (defaultLegend as any)._contentMaxHeight; + expect(defaultPager.attribute.y).toBeCloseTo(defaultMaxHeight - defaultPager.AABBBounds.height(), 4); + + // hugContent:翻页器紧贴内容下缘,分页视口收缩到实际内容高 + const hugPager = (hugLegend as any)._pagerComponent; + const contentHeight = (hugLegend as any)._itemsContainer.AABBBounds.height(); + expect(hugPager.attribute.y).toBeCloseTo(contentHeight + 10, 4); + expect((hugLegend as any)._itemContext.clipContainer.attribute.height).toBeCloseTo(contentHeight, 4); + + // 整体包围盒随内容收缩 + expect(hugLegend.AABBBounds.height()).toBeLessThan(defaultLegend.AABBBounds.height()); + }); +}); diff --git a/packages/vrender-components/src/legend/discrete/discrete.ts b/packages/vrender-components/src/legend/discrete/discrete.ts index 472473df4..3ee786c8e 100644 --- a/packages/vrender-components/src/legend/discrete/discrete.ts +++ b/packages/vrender-components/src/legend/discrete/discrete.ts @@ -809,6 +809,8 @@ export class DiscreteLegend extends LegendBase { const { pager } = this.attribute; const { totalPage, isHorizontal } = this._itemContext; const position = (pager && (pager as LegendPagerAttributes).position) || 'middle'; + const hugContent = !!(pager && (pager as LegendPagerAttributes).hugContent); + const pagerSpace = (pager && (pager as LegendPagerAttributes).space) ?? DEFAULT_PAGER_SPACE; (this._pagerComponent as Pager).setTotal(totalPage); if (isHorizontal) { @@ -820,8 +822,13 @@ export class DiscreteLegend extends LegendBase { } else { y = renderStartY + compHeight / 2 - this._pagerComponent.AABBBounds.height() / 2; } + let x = compWidth - this._pagerComponent.AABBBounds.width(); + if (hugContent) { + // 分页器紧贴内容右缘,内容占满可用空间时退化为原有的贴右缘布局 + x = Math.max(0, Math.min(this._itemsContainer.AABBBounds.width() + pagerSpace, x)); + } this._pagerComponent.setAttributes({ - x: compWidth - this._pagerComponent.AABBBounds.width(), + x, y }); } else { @@ -833,9 +840,14 @@ export class DiscreteLegend extends LegendBase { } else { x = (compWidth - this._pagerComponent.AABBBounds.width()) / 2; } + let y = compHeight - this._pagerComponent.AABBBounds.height(); + if (hugContent) { + // 分页器紧贴内容下缘,内容占满可用空间时退化为原有的贴下缘布局 + y = Math.max(0, Math.min(renderStartY + this._itemsContainer.AABBBounds.height() + pagerSpace, y)); + } this._pagerComponent.setAttributes({ x, - y: compHeight - this._pagerComponent.AABBBounds.height() + y }); } } @@ -1095,11 +1107,23 @@ export class DiscreteLegend extends LegendBase { } } + // hugContent 时分页裁剪视口收缩到实际内容尺寸:定宽(高)的 clip 分组会把图例包围盒 + // 固定在整个可用空间上,导致「图例项 + 分页器」无法作为整块被外部布局居中 + const hugContent = !!(pager as LegendPagerAttributes).hugContent; + let clipWidth = isHorizontal ? contentWidth : compWidth; + let clipHeight = isHorizontal ? compHeight : contentHeight; + if (hugContent) { + if (isHorizontal) { + clipWidth = Math.max(0, Math.min(clipWidth, itemsContainer.AABBBounds.width())); + } else { + clipHeight = Math.max(0, Math.min(clipHeight, itemsContainer.AABBBounds.height())); + } + } const clipGroup = graphicCreator.group({ x: 0, y: renderStartY, - width: isHorizontal ? contentWidth : compWidth, - height: isHorizontal ? compHeight : contentHeight, + width: clipWidth, + height: clipHeight, clip: true, pickable: false }); diff --git a/packages/vrender-components/src/legend/discrete/type.ts b/packages/vrender-components/src/legend/discrete/type.ts index 4ca0def55..7d48f8bd4 100644 --- a/packages/vrender-components/src/legend/discrete/type.ts +++ b/packages/vrender-components/src/legend/discrete/type.ts @@ -44,6 +44,14 @@ export type LegendPagerAttributes = Omit & * @default 'middle' */ position?: 'start' | 'middle' | 'end'; + /** + * 是否让分页器紧贴图例内容排布。默认 false,分页器固定在图例可用空间的末端, + * 图例项与分页器呈两端对齐;开启后分页器紧跟最后一个图例项(间距为 `space`), + * 同时分页裁剪视口收缩到实际内容尺寸,使「图例项 + 分页器」的包围盒随内容 + * 自适应,便于外部布局把整块内容居中放置。 + * @default false + */ + hugContent?: boolean; }; /**