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
100 changes: 100 additions & 0 deletions __tests__/unit/designs/items/badge-card.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/** @jsxImportSource ../../../../src */
import { describe, expect, it } from 'vitest';
import { getElementBounds } from '../../../../src';
import { BadgeCard } from '../../../../src/designs/items/BadgeCard';
import type { ThemeColors } from '../../../../src/themes';
import type { ItemDatum, ParsedData } from '../../../../src/types';

const themeColors: ThemeColors = {
colorPrimary: '#ce422b',
colorBg: '#ffffff',
colorWhite: '#ffffff',
isDarkMode: false,
colorPrimaryBg: '#ce422b1a',
colorText: '#262626',
colorTextSecondary: '#5a5a5a',
colorPrimaryText: '#ffffff',
colorBgElevated: '#ffffff',
};

const LONG_DESC =
'Collections implement IntoIterator to produce iterators via into_iter, iter, or iter_mut.';
const SHORT_DESC = 'Store closures as struct fields.';

const makeData = (items: ItemDatum[]): ParsedData => ({ items });

// 卡片背景 Rect 是 Group 的第一个 shape,覆盖范围即卡片高度
const getCardHeight = (data: ParsedData, index = 0) =>
getElementBounds(
<BadgeCard
indexes={[index]}
datum={data.items[index]}
data={data}
themeColors={themeColors}
/>,
).height;

describe('BadgeCard', () => {
it('grows tall enough to contain a description that wraps past two lines', () => {
const data = makeData([{ label: 'Iterator Basics', desc: LONG_DESC }]);

// descY(48) + 3 行 × 1.2 × 12 = 48 + 44 + gap(8) = 100
expect(getCardHeight(data)).toBe(100);
});

it('keeps the compact height for descriptions that fit in two lines', () => {
const data = makeData([{ label: 'Closure in Struct', desc: SHORT_DESC }]);

// descY(48) + 2 行 × 1.2 × 12 = 48 + 29 + gap(8) = 85
expect(getCardHeight(data)).toBe(85);
});

it('gives every item the same height so grid cells stay aligned', () => {
const data = makeData([
{ label: 'Closure in Struct', desc: SHORT_DESC },
{ label: 'Iterator Basics', desc: LONG_DESC },
]);

// 首项 desc 较短,但高度必须按整批最长的 desc 统一
expect(getCardHeight(data, 0)).toBe(getCardHeight(data, 1));
expect(getCardHeight(data, 0)).toBe(100);
});

it('falls back to the compact height when no item has a description', () => {
const data = makeData([{ label: 'Closure Basics' }]);

expect(getCardHeight(data)).toBe(80);
});

it('respects an explicitly provided height', () => {
const data = makeData([{ label: 'Iterator Basics', desc: LONG_DESC }]);

expect(
getElementBounds(
<BadgeCard
indexes={[0]}
datum={data.items[0]}
data={data}
themeColors={themeColors}
height={120}
/>,
).height,
).toBe(120);
});

it('reserves two lines when the datum is not one of data.items', () => {
// sequence-interaction 的泳道标题即如此:datum 由结构临时拼装,不在 data.items 中
const data = makeData([{ label: 'Node' }]);

expect(
getElementBounds(
<BadgeCard
indexes={[0]}
datum={{ label: 'Lane', desc: LONG_DESC }}
data={data}
themeColors={themeColors}
/>,
).height,
).toBe(85);
});
});
47 changes: 47 additions & 0 deletions __tests__/unit/utils/measure-text-lines.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { measureTextLines } from '../../../src/utils/measure-text';

// 与 BadgeCard 描述文本一致的排版参数:字号 12(不低于浏览器最小字号下限)、卡片内容宽 184px
const attrs = { fontSize: 12, lineHeight: 1.2, maxWidth: 184 };

describe('measureTextLines', () => {
it('reports the extra lines a long description actually needs', () => {
expect(
measureTextLines(
'Collections implement IntoIterator to produce iterators via into_iter, iter, or iter_mut.',
attrs,
),
).toBe(3);
});

it('keeps a short description within two lines', () => {
expect(
measureTextLines('Store closures as struct fields.', attrs),
).toBeLessThanOrEqual(2);
});

it('keeps a label-sized string on one line', () => {
expect(measureTextLines('Closure Basics', attrs)).toBe(1);
});

it('wraps CJK text character by character', () => {
expect(
measureTextLines(
'这是一段比较长的中文描述文本用于验证换行测量是否正确',
attrs,
),
).toBeGreaterThan(1);
});

it('breaks a single word that overflows the container', () => {
expect(measureTextLines('A'.repeat(200), attrs)).toBeGreaterThan(1);
});

it('scales the line count with the available width', () => {
const text = 'Consumers like sum and collect consume iterators lazily.';

expect(measureTextLines(text, { ...attrs, maxWidth: 90 })).toBeGreaterThan(
measureTextLines(text, { ...attrs, maxWidth: 300 }),
);
});
});
56 changes: 56 additions & 0 deletions __tests__/unit/utils/measure-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,3 +235,59 @@ describe('measureText', () => {
expect(fallbackMeasureText).toHaveBeenCalledTimes(1);
});
});

describe('measureTextLines', () => {
const attrs = { fontSize: 12, lineHeight: 1.2, maxWidth: 184 };

it('returns 0 for empty or non-text content', async () => {
const { measureTextLines } =
await import('../../../src/utils/measure-text');

expect(measureTextLines('', attrs)).toBe(0);
expect(measureTextLines(undefined, attrs)).toBe(0);
expect(measureTextLines({} as any, attrs)).toBe(0);
});

it('counts explicit newlines as separate lines', async () => {
const { measureTextLines } =
await import('../../../src/utils/measure-text');

expect(measureTextLines('a\nb\nc', attrs)).toBe(3);
});

it('falls back to newline splitting when maxWidth is unusable', async () => {
const { measureTextLines } =
await import('../../../src/utils/measure-text');

expect(measureTextLines('a\nb', { ...attrs, maxWidth: 0 })).toBe(2);
});

it('caches the line count within a withTextLinesCache scope', async () => {
const { measureTextLines, withTextLinesCache } =
await import('../../../src/utils/measure-text');

fallbackMeasureText.mockClear();
const callsAfterFirst = withTextLinesCache(() => {
measureTextLines('cache probe', attrs);
const calls = fallbackMeasureText.mock.calls.length;
measureTextLines('cache probe', attrs);
expect(fallbackMeasureText).toHaveBeenCalledTimes(calls);
return calls;
});

expect(callsAfterFirst).toBeGreaterThan(0);
});

it('does not cache across withTextLinesCache scopes', async () => {
const { measureTextLines, withTextLinesCache } =
await import('../../../src/utils/measure-text');

fallbackMeasureText.mockClear();
withTextLinesCache(() => measureTextLines('cross scope probe', attrs));
const callsAfterFirst = fallbackMeasureText.mock.calls.length;
withTextLinesCache(() => measureTextLines('cross scope probe', attrs));

expect(callsAfterFirst).toBeGreaterThan(0);
expect(fallbackMeasureText.mock.calls.length).toBe(callsAfterFirst * 2);
});
});
45 changes: 37 additions & 8 deletions src/designs/items/BadgeCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import tinycolor from 'tinycolor2';
import { ComponentType, Defs, Ellipse, Group, Rect } from '../../jsx';
import { measureTextLines } from '../../utils';
import { ItemDesc, ItemIcon, ItemLabel, ItemValue } from '../components';
import { FlexLayout } from '../layouts';
import { getItemProps } from '../utils';
Expand All @@ -14,13 +15,19 @@ export interface BadgeCardProps extends BaseItemProps {
gap?: number;
}

const DESC_FONT_SIZE = 12;
const DESC_LINE_HEIGHT = 1.2;
const MIN_DESC_LINE_NUMBER = 2;
const DEFAULT_HEIGHT = 80;

export const BadgeCard: ComponentType<BadgeCardProps> = (props) => {
const [
{
datum,
data,
indexes,
width = 200,
height = 80,
height,
iconSize = 24,
badgeSize = 32,
gap = 8,
Expand Down Expand Up @@ -49,10 +56,32 @@ export const BadgeCard: ComponentType<BadgeCardProps> = (props) => {
const descY = gap + 14 + 18 + 8; // label(14) + value(18) + gap(8)
const contentAreaHeight = descY - gap; // label 和 value 占据的总高度

// 描述按实际折行行数占位,避免长文本溢出卡片背景。
// 网格类结构以首项尺寸作为所有单元格基准,因此高度需按整批 item 统一计算。
// NOTE: 层级结构中 data.items 是根节点、渲染的却是其 children,此处取不到同级集合,
// 这类结构暂时维持原有的固定两行占位。
const siblings = data.items;
const descLineNumber = Math.max(
...siblings.map((item) =>
measureTextLines(item.desc, {
fontSize: DESC_FONT_SIZE,
lineHeight: DESC_LINE_HEIGHT,
maxWidth: fullWidth,
}),
),
MIN_DESC_LINE_NUMBER,
);
const descHeight = Math.ceil(
descLineNumber * DESC_LINE_HEIGHT * DESC_FONT_SIZE,
);
const hasAnyDesc = siblings.some((item) => !!item.desc) || hasDesc;
const finalHeight =
height ?? (hasAnyDesc ? descY + descHeight + gap : DEFAULT_HEIGHT);

// 当没有 desc 时,徽章和内容区域垂直居中
const badgeY = !hasDesc ? (height - badgeSize) / 2 : gap;
const badgeY = !hasDesc ? (finalHeight - badgeSize) / 2 : gap;
// 没有 value 时,label 在整个内容区域垂直居中;有 value 时从顶部开始
const contentY = !hasValue && !hasDesc ? (height - 14) / 2 : gap;
const contentY = !hasValue && !hasDesc ? (finalHeight - 14) / 2 : gap;

const textAlign =
!hasIcon && positionH === 'center'
Expand All @@ -62,7 +91,7 @@ export const BadgeCard: ComponentType<BadgeCardProps> = (props) => {
: 'left';

return (
<Group {...restProps} width={width} height={height}>
<Group {...restProps} width={width} height={finalHeight}>
<Defs>
<radialGradient id={gradientId} cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor={themeColors.colorPrimary} />
Expand All @@ -81,7 +110,7 @@ export const BadgeCard: ComponentType<BadgeCardProps> = (props) => {
x={0}
y={0}
width={width}
height={height}
height={finalHeight}
fill={themeColors.colorPrimaryBg}
rx={8}
ry={8}
Expand Down Expand Up @@ -156,10 +185,10 @@ export const BadgeCard: ComponentType<BadgeCardProps> = (props) => {
y={descY}
width={fullWidth}
alignHorizontal={textAlign}
fontSize={11}
fontSize={DESC_FONT_SIZE}
fill={themeColors.colorTextSecondary}
lineNumber={2}
lineHeight={1.2}
lineNumber={descLineNumber}
lineHeight={DESC_LINE_HEIGHT}
wordWrap={true}
>
{datum.desc}
Expand Down
2 changes: 2 additions & 0 deletions src/designs/structures/sequence-interaction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ export const SequenceInteractionFlow: ComponentType<
<Item
indexes={[0]}
datum={sampleNode.datum}
data={data}
positionH="center"
positionV="middle"
/>,
Expand Down Expand Up @@ -440,6 +441,7 @@ export const SequenceInteractionFlow: ComponentType<
icon: lane.icon,
desc: lane.desc,
}}
data={data}
x={centerX - itemWidth / 2}
y={padding}
width={itemWidth}
Expand Down
24 changes: 14 additions & 10 deletions src/runtime/Infographic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { DEFAULT_FONT, Renderer, setDefaultFont } from '../renderer';
import { waitForSvgLoads } from '../resource';
import { parseSyntax, type SyntaxError } from '../syntax';
import { IEventEmitter } from '../types';
import { getTypes, parseSVG } from '../utils';
import { getTypes, parseSVG, withTextLinesCache } from '../utils';
import { DEFAULT_OPTIONS } from './options';
import {
cloneOptions,
Expand Down Expand Up @@ -146,15 +146,19 @@ export class Infographic {
if (themeFontFamily) setDefaultFont(themeFontFamily);

try {
const svg = renderSVG(
<Structure
data={data}
Title={Title}
Item={Item}
Items={Items}
options={parsedOptions}
{...structureProps}
/>,
// Text measurements are memoized per compose pass only — font metrics can
// change between renders once web fonts finish loading.
const svg = withTextLinesCache(() =>
renderSVG(
<Structure
data={data}
Title={Title}
Item={Item}
Items={Items}
options={parsedOptions}
{...structureProps}
/>,
),
);

const template = parseSVG(svg);
Expand Down
Loading
Loading