diff --git a/__tests__/unit/designs/items/badge-card.test.tsx b/__tests__/unit/designs/items/badge-card.test.tsx new file mode 100644 index 000000000..3022071a0 --- /dev/null +++ b/__tests__/unit/designs/items/badge-card.test.tsx @@ -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( + , + ).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( + , + ).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( + , + ).height, + ).toBe(85); + }); +}); diff --git a/__tests__/unit/utils/measure-text-lines.test.ts b/__tests__/unit/utils/measure-text-lines.test.ts new file mode 100644 index 000000000..b9c0d0007 --- /dev/null +++ b/__tests__/unit/utils/measure-text-lines.test.ts @@ -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 }), + ); + }); +}); diff --git a/__tests__/unit/utils/measure-text.test.ts b/__tests__/unit/utils/measure-text.test.ts index 37a5304c4..b0e75926b 100644 --- a/__tests__/unit/utils/measure-text.test.ts +++ b/__tests__/unit/utils/measure-text.test.ts @@ -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); + }); +}); diff --git a/src/designs/items/BadgeCard.tsx b/src/designs/items/BadgeCard.tsx index 74a1fc905..202516fea 100644 --- a/src/designs/items/BadgeCard.tsx +++ b/src/designs/items/BadgeCard.tsx @@ -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'; @@ -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 = (props) => { const [ { datum, + data, indexes, width = 200, - height = 80, + height, iconSize = 24, badgeSize = 32, gap = 8, @@ -49,10 +56,32 @@ export const BadgeCard: ComponentType = (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' @@ -62,7 +91,7 @@ export const BadgeCard: ComponentType = (props) => { : 'left'; return ( - + @@ -81,7 +110,7 @@ export const BadgeCard: ComponentType = (props) => { x={0} y={0} width={width} - height={height} + height={finalHeight} fill={themeColors.colorPrimaryBg} rx={8} ry={8} @@ -156,10 +185,10 @@ export const BadgeCard: ComponentType = (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} diff --git a/src/designs/structures/sequence-interaction.tsx b/src/designs/structures/sequence-interaction.tsx index 9150200db..3842b9e4e 100644 --- a/src/designs/structures/sequence-interaction.tsx +++ b/src/designs/structures/sequence-interaction.tsx @@ -364,6 +364,7 @@ export const SequenceInteractionFlow: ComponentType< , @@ -440,6 +441,7 @@ export const SequenceInteractionFlow: ComponentType< icon: lane.icon, desc: lane.desc, }} + data={data} x={centerX - itemWidth / 2} y={padding} width={itemWidth} diff --git a/src/runtime/Infographic.tsx b/src/runtime/Infographic.tsx index 1db3845f7..29d142891 100644 --- a/src/runtime/Infographic.tsx +++ b/src/runtime/Infographic.tsx @@ -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, @@ -146,15 +146,19 @@ export class Infographic { if (themeFontFamily) setDefaultFont(themeFontFamily); try { - const svg = renderSVG( - , + // Text measurements are memoized per compose pass only — font metrics can + // change between renders once web fonts finish loading. + const svg = withTextLinesCache(() => + renderSVG( + , + ), ); const template = parseSVG(svg); diff --git a/src/utils/measure-text.ts b/src/utils/measure-text.ts index d7728d3b1..cb48f94a9 100644 --- a/src/utils/measure-text.ts +++ b/src/utils/measure-text.ts @@ -168,3 +168,138 @@ export function measureText( height: Math.ceil(metrics.height * FONT_EXTEND_FACTOR), }; } + +// 断行单元:CJK 逐字断行,其余按连续非空白片段(单词)断行 +const CJK_RANGE = + '\\u2e80-\\u9fff\\uac00-\\ud7ff\\uf900-\\ufaff\\ufe30-\\ufe4f\\uff00-\\uffef'; +const BREAK_UNIT_REGEX = new RegExp( + `\\s*(?:[${CJK_RANGE}]|[^\\s${CJK_RANGE}]+)`, + 'g', +); + +// 单个断行单元仍超出容器宽度时,模拟 word-break: break-word 强制断开 +function breakLongUnit( + unit: string, + maxWidth: number, + widthOf: (value: string) => number, +) { + let rest = unit; + let extraLines = 0; + + while (rest.length > 1 && widthOf(rest) > maxWidth) { + let fitted = 1; + while ( + fitted < rest.length && + widthOf(rest.slice(0, fitted + 1)) <= maxWidth + ) { + fitted++; + } + if (fitted >= rest.length) break; + extraLines++; + rest = rest.slice(fitted); + } + + return { extraLines, rest }; +} + +function countWrappedLines( + line: string, + maxWidth: number, + widthOf: (value: string) => number, +) { + const units = line.match(BREAK_UNIT_REGEX); + if (!units) return 1; + + let lines = 1; + let current = ''; + + for (const unit of units) { + const candidate = current ? current + unit : unit.trimStart(); + if (!candidate) continue; + + if (!current || widthOf(candidate) <= maxWidth) { + current = candidate; + } else { + lines++; + current = unit.trimStart(); + } + + const { extraLines, rest } = breakLongUnit(current, maxWidth, widthOf); + lines += extraLines; + current = rest; + } + + return lines; +} + +// 同一批 item 会互相测量彼此的文本,缓存行数避免 O(n²) 的重复折行计算。 +// 仅在单次渲染内复用:Web 字体是渲染后才注入的,加载完成前后同一段文字的 +// 度量结果不同,跨渲染复用会让行数停留在回退字体的测量值。 +let lineCountCache: Map | null = null; + +/** 在一次渲染范围内复用折行测量结果,作用域外不缓存 */ +export function withTextLinesCache(render: () => T): T { + const previous = lineCountCache; + lineCountCache = new Map(); + try { + return render(); + } finally { + lineCountCache = previous; + } +} + +function getLineCountCacheKey( + content: string, + attrs: TextProps & { maxWidth: number }, +) { + const { + maxWidth, + fontFamily = DEFAULT_FONT, + fontSize = 14, + fontWeight = 'normal', + lineHeight = 1.4, + } = attrs; + return [ + maxWidth, + fontFamily, + fontSize, + fontWeight, + lineHeight, + FONT_EXTEND_FACTOR, + content, + ].join('|'); +} + +/** + * 测量文本在给定宽度内折行后的行数,与渲染层的换行行为对齐。 + */ +export function measureTextLines( + text: JSXNode = '', + attrs: TextProps & { maxWidth: number }, +): number { + if (typeof text !== 'string' && typeof text !== 'number') return 0; + const content = text.toString(); + if (!content) return 0; + + const { maxWidth } = attrs; + const lines = content.split(/\r?\n/); + if (!Number.isFinite(maxWidth) || maxWidth <= 0) return lines.length; + + const compute = () => { + // 测量单个片段的自然宽度,不能带入容器宽高(会短路 measureText) + const textAttrs = { ...attrs, width: undefined, height: undefined }; + const widthOf = (value: string) => measureText(value, textAttrs).width; + return lines.reduce( + (count, line) => count + countWrappedLines(line, maxWidth, widthOf), + 0, + ); + }; + + if (!lineCountCache) return compute(); + + const cacheKey = getLineCountCacheKey(content, attrs); + let total = lineCountCache.get(cacheKey); + if (total === undefined) lineCountCache.set(cacheKey, (total = compute())); + + return total; +}