From fbc25a900bf30bf53534fdd7c8618a163aa41559 Mon Sep 17 00:00:00 2001 From: skie1997 Date: Thu, 11 Jun 2026 11:26:16 +0800 Subject: [PATCH 1/8] feat: add storyline chart to extension --- .../runtime/browser/test-page/storyline.ts | 283 ++++++++++++ .../src/charts/storyline/index.ts | 4 + .../src/charts/storyline/interface.ts | 103 +++++ .../src/charts/storyline/layout.ts | 370 +++++++++++++++ .../src/charts/storyline/layouts/bowl.ts | 425 ++++++++++++++++++ .../src/charts/storyline/layouts/clock.ts | 385 ++++++++++++++++ .../src/charts/storyline/layouts/common.ts | 311 +++++++++++++ .../src/charts/storyline/layouts/default.ts | 262 +++++++++++ .../src/charts/storyline/layouts/dome.ts | 425 ++++++++++++++++++ .../src/charts/storyline/layouts/landscape.ts | 349 ++++++++++++++ .../src/charts/storyline/layouts/portrait.ts | 320 +++++++++++++ .../charts/storyline/storyline-transformer.ts | 126 ++++++ .../src/charts/storyline/storyline.ts | 61 +++ packages/vchart-extension/src/index.ts | 1 + 14 files changed, 3425 insertions(+) create mode 100644 packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts create mode 100644 packages/vchart-extension/src/charts/storyline/index.ts create mode 100644 packages/vchart-extension/src/charts/storyline/interface.ts create mode 100644 packages/vchart-extension/src/charts/storyline/layout.ts create mode 100644 packages/vchart-extension/src/charts/storyline/layouts/bowl.ts create mode 100644 packages/vchart-extension/src/charts/storyline/layouts/clock.ts create mode 100644 packages/vchart-extension/src/charts/storyline/layouts/common.ts create mode 100644 packages/vchart-extension/src/charts/storyline/layouts/default.ts create mode 100644 packages/vchart-extension/src/charts/storyline/layouts/dome.ts create mode 100644 packages/vchart-extension/src/charts/storyline/layouts/landscape.ts create mode 100644 packages/vchart-extension/src/charts/storyline/layouts/portrait.ts create mode 100644 packages/vchart-extension/src/charts/storyline/storyline-transformer.ts create mode 100644 packages/vchart-extension/src/charts/storyline/storyline.ts diff --git a/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts b/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts new file mode 100644 index 0000000000..a07de987d6 --- /dev/null +++ b/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts @@ -0,0 +1,283 @@ +import { VChart } from '@visactor/vchart'; +import { registerStorylineChart } from '../../../../src'; +import type { IStorylineSpec, StorylineLayoutType } from '../../../../src/charts/storyline'; + +const layouts: StorylineLayoutType[] = [ + 'landscape', + 'portrait', + 'up-ladder', + 'down-ladder', + 'pulse', + 'spiral', + 'clock', + 'bowl', + 'dome', + 'left-wing', + 'right-wing' +]; + +const baseData = [ + { + id: 'discover', + title: 'Discover', + content: + 'Collect the first signal and frame the story. Capture every relevant detail from the source material ' + + 'so the audience can reconstruct the same context the author had when starting the analysis.', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'group', + title: 'Group', + content: + 'Arrange related facts into a compact block, removing duplicates and aligning each fragment ' + + 'to the central theme so readers can scan supporting evidence at a glance without losing context.', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'connect', + title: 'Connect', + content: + 'Draw the reading path between blocks. Use repeating motifs, parallel sentence structures ' + + 'and visual cues to establish a continuous flow that walks the reader from premise to conclusion.', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'emphasize', + title: 'Emphasize', + content: + 'Use image, title, and copy as one visual unit. Highlight the most important facts with typography ' + + 'weight, color contrast or motion so the eye instinctively returns to them while scanning.', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'resolve', + title: 'Resolve', + content: + 'End with a clear takeaway. Summarize the lesson, point out the next decision the audience ' + + 'should make and remove any ambiguity so the story closes with a satisfying, actionable conclusion.', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + } +]; + +const randomCountByLayout = layouts.reduce>( + (result, layout) => { + result[layout] = 3 + Math.floor(Math.random() * 7); + return result; + }, + {} as Record +); + +const buildData = (layout: StorylineLayoutType) => { + const count = randomCountByLayout[layout]; + return Array.from({ length: count }, (_, index) => { + const seed = baseData[index % baseData.length]; + return { + ...seed, + id: `${layout}-${index}-${seed.id}`, + title: `${seed.title} ${index + 1}`, + content: [`${seed.content}`, `Layout ${layout} / Block ${index + 1} of ${count}.`] + }; + }); +}; + +// 通用 title / content 样式(所有布局共享) +const commonTitle: IStorylineSpec['title'] = { + style: { + fontSize: 14, + fill: '#1f2533', + fontWeight: 700 + } +}; + +const commonContent: IStorylineSpec['content'] = { + style: { + fontSize: 12, + lineHeight: 17, + fill: '#596579' + } +}; + +const commonLine: IStorylineSpec['line'] = { + type: 'line', + showArrow: true, + style: { + lineWidth: 1.5, + lineCap: 'round', + lineJoin: 'round', + lineDash: [6, 5] + } +}; + +const themeColor = 'rgb(228,154,56)'; + +// landscape:图片错落 + 贯穿曲线,block 含上下两个卡片,垂直空间更大 +const createLandscapeSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ + type: 'storyline', + padding: 20, + data: buildData(layout), + layout, + themeColor, + block: { + widthRatio: 0.22, + minWidth: 200, + maxWidth: 260, + height: 260, + padding: 12, + gap: 40, + style: { fill: '#ffffff', stroke: '#d8deea', lineWidth: 1, cornerRadius: 8 } + }, + image: { gap: 0 }, + title: commonTitle, + content: commonContent, + line: commonLine +}); + +// portrait:上下预留 50px,中轴贯穿,block.height 由 transformer 自适应 +const createPortraitSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ + type: 'storyline', + padding: [50, 20, 50, 20], + data: buildData(layout), + layout, + themeColor, + block: { + widthRatio: 0.28, + minWidth: 220, + maxWidth: 320, + padding: 12, + gap: 40, + style: { fill: '#ffffff', stroke: '#d8deea', lineWidth: 1, cornerRadius: 8 } + }, + image: { gap: 0 }, + title: commonTitle, + content: commonContent, + line: commonLine +}); + +// bowl:顶部 50 / 底部 10 留白以承载弧线 + centerImage +const createDomeSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ + type: 'storyline', + padding: [50, 20, 100, 20], + data: buildData(layout), + layout, + themeColor, + block: { + widthRatio: 0.28, + minWidth: 220, + maxWidth: 320, + height: 300, + padding: 12, + gap: 40, + style: { fill: '#ffffff', stroke: '#d8deea', lineWidth: 1, cornerRadius: 8 } + }, + image: { position: 'left', gap: 12 }, + title: commonTitle, + content: commonContent, + line: commonLine, + centerImage: { + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + } +}); + +// clock:环绕式时间线,需要 centerImage 作为盘心 +const createClockSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ + type: 'storyline', + padding: 20, + data: buildData(layout), + layout, + themeColor, + block: { + widthRatio: 0.28, + minWidth: 220, + maxWidth: 320, + padding: 12, + gap: 40, + style: { fill: '#ffffff', stroke: '#d8deea', lineWidth: 1, cornerRadius: 8 } + }, + image: { position: 'left', gap: 12 }, + title: commonTitle, + content: commonContent, + line: commonLine, + centerImage: { + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + } +}); + +// 默认 / clock / ladder / pulse / spiral / dome / wing 等布局共用一份 spec +const createDefaultSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ + type: 'storyline', + padding: 20, + data: buildData(layout), + layout, + themeColor, + block: { + widthRatio: 0.28, + minWidth: 220, + maxWidth: 320, + height: 132, + padding: 12, + gap: 40, + style: { fill: '#ffffff', stroke: '#d8deea', lineWidth: 1, cornerRadius: 8 } + }, + image: { position: 'left', gap: 12 }, + title: commonTitle, + content: commonContent, + line: commonLine +}); + +const specBuilderByLayout: Partial IStorylineSpec>> = { + landscape: createLandscapeSpec, + portrait: createPortraitSpec, + clock: createClockSpec, + dome: createDomeSpec +}; + +const createSpec = (layout: StorylineLayoutType): IStorylineSpec => { + const builder = specBuilderByLayout[layout] ?? createDefaultSpec; + return builder(layout); +}; + +declare global { + interface Window { + vchart?: VChart; + } +} + +const run = () => { + registerStorylineChart(); + + const container = document.getElementById('chart') as HTMLElement; + const toolbar = document.createElement('div'); + toolbar.style.cssText = 'position:absolute;left:16px;top:16px;z-index:1;font:12px sans-serif;'; + const select = document.createElement('select'); + layouts.forEach(layout => { + const option = document.createElement('option'); + option.value = layout; + option.textContent = layout; + select.appendChild(option); + }); + toolbar.appendChild(select); + container?.parentElement?.appendChild(toolbar); + + let cs: VChart | undefined; + + const render = (layout: StorylineLayoutType) => { + cs?.release(); + cs = new VChart(createSpec(layout) as any, { + dom: container, + onError: err => { + console.error(err); + } + }); + cs.renderSync(); + window.vchart = cs; + }; + + select.value = layouts[6]; + render(select.value as StorylineLayoutType); + + select.addEventListener('change', () => { + render(select.value as StorylineLayoutType); + }); +}; + +run(); diff --git a/packages/vchart-extension/src/charts/storyline/index.ts b/packages/vchart-extension/src/charts/storyline/index.ts new file mode 100644 index 0000000000..2c4b70f598 --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/index.ts @@ -0,0 +1,4 @@ +export * from './interface'; +export * from './layout'; +export * from './storyline'; +export * from './storyline-transformer'; diff --git a/packages/vchart-extension/src/charts/storyline/interface.ts b/packages/vchart-extension/src/charts/storyline/interface.ts new file mode 100644 index 0000000000..9b2b481fe8 --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/interface.ts @@ -0,0 +1,103 @@ +import type { + IChartSpec, + IComposedTextMarkSpec, + IImageMarkSpec, + IMarkSpec, + IPathMarkSpec, + IRectMarkSpec, + ITextMarkSpec, + StringOrNumber +} from '@visactor/vchart'; + +export type StorylineLayoutType = + | 'clock' + | 'bowl' + | 'dome' + | 'left-wing' + | 'right-wing' + | 'landscape' + | 'portrait' + | 'up-ladder' + | 'down-ladder' + | 'pulse' + | 'spiral'; + +export type StorylineImagePosition = 'top' | 'left' | 'right' | 'bottom'; +export type StorylineLineType = 'line' | 'polyline' | 'curve'; + +export interface IStorylineBlock { + id?: StringOrNumber; + title?: string; + content?: string | string[]; + image?: string | HTMLImageElement | HTMLCanvasElement; + datum?: unknown; +} + +export interface IStorylineLayoutOptions { + type: StorylineLayoutType; + /** + * 边缘留白,支持单值或 [top, right, bottom, left]。 + */ + padding?: number | [number, number, number, number]; + /** + * 对 circular/arc 布局生效,控制半径占可用空间的比例。 + */ + radiusRatio?: number; + /** + * 对 circular/arc 布局生效,角度单位为度。 + */ + startAngle?: number; + /** + * 对 circular/arc 布局生效,角度单位为度。 + */ + endAngle?: number; +} + +export interface IStorylineBlockSpec { + width?: number; + widthRatio?: number; + minWidth?: number; + maxWidth?: number; + height?: number; + padding?: number | [number, number, number, number]; + gap?: number; + style?: Partial; +} + +export interface IStorylineImageSpec extends IMarkSpec { + width?: number; + height?: number; + position?: StorylineImagePosition; + gap?: number; +} + +export interface IStorylineCenterImageSpec extends IMarkSpec { + width?: number; + height?: number; + visible?: boolean; + image?: string | HTMLImageElement | HTMLCanvasElement; +} + +export interface IStorylineLineSpec extends IMarkSpec { + visible?: boolean; + type?: StorylineLineType; + showArrow?: boolean; + arrowSize?: number; + /** + * 连接线和 block 边缘之间的距离。 + */ + distance?: number; +} + +export interface IStorylineSpec extends Omit { + type: 'storyline'; + data: IStorylineBlock[]; + layout?: StorylineLayoutType | IStorylineLayoutOptions; + block?: IStorylineBlockSpec; + title?: IMarkSpec; + content?: IMarkSpec; + image?: IStorylineImageSpec; + centerImage?: IStorylineCenterImageSpec; + line?: IStorylineLineSpec; + themeColor?: string; +} diff --git a/packages/vchart-extension/src/charts/storyline/layout.ts b/packages/vchart-extension/src/charts/storyline/layout.ts new file mode 100644 index 0000000000..3c63a1a681 --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/layout.ts @@ -0,0 +1,370 @@ +import type { IStorylineBlock, IStorylineLayoutOptions, StorylineLayoutType } from './interface'; + +export interface StorylineSize { + width: number; + height: number; +} + +export interface StorylinePadding { + top: number; + right: number; + bottom: number; + left: number; +} + +export interface StorylinePoint { + x: number; + y: number; +} + +export interface StorylineBlockPosition extends StorylinePoint, StorylineSize { + id: string | number; + index: number; + datum: IStorylineBlock; + center: StorylinePoint; +} + +export interface StorylineLinkPosition { + from: StorylineBlockPosition; + to: StorylineBlockPosition; + start: StorylinePoint; + end: StorylinePoint; + points: StorylinePoint[]; +} + +export interface StorylineCircleGuide { + center: StorylinePoint; + radius: number; +} + +export interface StorylineLayoutResult { + blocks: StorylineBlockPosition[]; + links: StorylineLinkPosition[]; + circleGuide?: StorylineCircleGuide; +} + +export interface StorylineComputeOptions { + layout: StorylineLayoutType | IStorylineLayoutOptions | undefined; + viewBox: StorylineSize; + block: StorylineSize; + gap?: number; + padding?: number | [number, number, number, number]; + lineDistance?: number; +} + +const DEFAULT_LAYOUT: StorylineLayoutType = 'landscape'; +const DEFAULT_PADDING = 24; + +export const normalizePadding = (padding?: number | [number, number, number, number]): StorylinePadding => { + if (Array.isArray(padding)) { + return { + top: padding[0] ?? 0, + right: padding[1] ?? 0, + bottom: padding[2] ?? 0, + left: padding[3] ?? 0 + }; + } + const value = padding ?? DEFAULT_PADDING; + return { top: value, right: value, bottom: value, left: value }; +}; + +export const normalizeLayout = (layout?: StorylineLayoutType | IStorylineLayoutOptions): IStorylineLayoutOptions => { + if (!layout) { + return { type: DEFAULT_LAYOUT }; + } + if (typeof layout === 'string') { + return { type: layout }; + } + return layout; +}; + +export const computeStorylineLayout = ( + data: IStorylineBlock[], + options: StorylineComputeOptions +): StorylineLayoutResult => { + const layout = normalizeLayout(options.layout); + const padding = normalizePadding(layout.padding ?? options.padding); + const gap = options.gap ?? 40; + const lineDistance = options.lineDistance ?? 8; + const blocks = computeBlockPositions(data, layout, options.viewBox, options.block, padding, gap); + const circleGuide = + layout.type === 'clock' ? computeClockCircleGuide(options.viewBox, options.block, padding, layout) : undefined; + return { + blocks, + links: computeLinks(blocks, lineDistance), + circleGuide + }; +}; + +const computeBlockPositions = ( + data: IStorylineBlock[], + layout: IStorylineLayoutOptions, + viewBox: StorylineSize, + block: StorylineSize, + padding: StorylinePadding, + gap: number +): StorylineBlockPosition[] => { + const count = data.length; + if (!count) { + return []; + } + + const inner = { + x: padding.left, + y: padding.top, + width: Math.max(viewBox.width - padding.left - padding.right, block.width), + height: Math.max(viewBox.height - padding.top - padding.bottom, block.height) + }; + const center = { + x: inner.x + inner.width / 2, + y: inner.y + inner.height / 2 + }; + + let centers: StorylinePoint[]; + switch (layout.type) { + case 'portrait': + centers = lineCenters( + count, + center.x, + inner.y + block.height / 2, + center.x, + inner.y + inner.height - block.height / 2 + ); + break; + case 'up-ladder': + centers = lineCenters( + count, + inner.x + block.width / 2, + inner.y + inner.height - block.height / 2, + inner.x + inner.width - block.width / 2, + inner.y + block.height / 2 + ); + break; + case 'down-ladder': + centers = lineCenters( + count, + inner.x + block.width / 2, + inner.y + block.height / 2, + inner.x + inner.width - block.width / 2, + inner.y + inner.height - block.height / 2 + ); + break; + case 'pulse': + centers = alternatingHorizontalCenters(count, inner, block, gap); + break; + case 'spiral': + centers = alternatingVerticalCenters(count, inner, block, gap); + break; + case 'clock': + centers = circularCenters(count, viewBox, block, padding, layout); + break; + case 'bowl': + centers = arcCenters(count, inner, block, layout, 200, 340); + break; + case 'dome': + centers = arcCenters(count, inner, block, layout, 160, 20); + break; + case 'left-wing': + centers = arcCenters(count, inner, block, layout, 250, 110); + break; + case 'right-wing': + centers = arcCenters(count, inner, block, layout, -70, 70); + break; + case 'landscape': + default: + centers = lineCenters( + count, + inner.x + block.width / 2, + center.y, + inner.x + inner.width - block.width / 2, + center.y + ); + break; + } + + return centers.map((point, index) => ({ + id: data[index]?.id ?? index, + index, + datum: data[index], + width: block.width, + height: block.height, + x: point.x - block.width / 2, + y: point.y - block.height / 2, + center: point + })); +}; + +const lineCenters = (count: number, x0: number, y0: number, x1: number, y1: number): StorylinePoint[] => { + if (count === 1) { + return [{ x: (x0 + x1) / 2, y: (y0 + y1) / 2 }]; + } + return Array.from({ length: count }, (_, index) => { + const t = index / (count - 1); + return { + x: x0 + (x1 - x0) * t, + y: y0 + (y1 - y0) * t + }; + }); +}; + +const alternatingHorizontalCenters = ( + count: number, + inner: { x: number; y: number; width: number; height: number }, + block: StorylineSize, + gap: number +) => { + const baseY = inner.y + inner.height / 2; + const offset = Math.min(Math.max(block.height * 0.65 + gap / 2, 0), Math.max((inner.height - block.height) / 2, 0)); + const points = lineCenters(count, inner.x + block.width / 2, baseY, inner.x + inner.width - block.width / 2, baseY); + return points.map((point, index) => ({ + x: point.x, + y: point.y + (index % 2 === 0 ? -offset : offset) + })); +}; + +const alternatingVerticalCenters = ( + count: number, + inner: { x: number; y: number; width: number; height: number }, + block: StorylineSize, + gap: number +) => { + const baseX = inner.x + inner.width / 2; + const offset = Math.min(Math.max(block.width * 0.65 + gap / 2, 0), Math.max((inner.width - block.width) / 2, 0)); + const points = lineCenters( + count, + baseX, + inner.y + block.height / 2, + baseX, + inner.y + inner.height - block.height / 2 + ); + return points.map((point, index) => ({ + x: point.x + (index % 2 === 0 ? -offset : offset), + y: point.y + })); +}; + +const circularCenters = ( + count: number, + viewBox: StorylineSize, + block: StorylineSize, + padding: StorylinePadding, + layout: IStorylineLayoutOptions +) => { + const guide = computeClockCircleGuide(viewBox, block, padding, layout); + const startAngle = layout.startAngle ?? -90; + const delta = 360; + + if (count === 1) { + const angle = degreeToRadian(startAngle); + return [ + { + x: guide.center.x + Math.cos(angle) * guide.radius, + y: guide.center.y + Math.sin(angle) * guide.radius + } + ]; + } + + return Array.from({ length: count }, (_, index) => { + const angle = degreeToRadian(startAngle + (delta * index) / count); + return { + x: guide.center.x + Math.cos(angle) * guide.radius, + y: guide.center.y + Math.sin(angle) * guide.radius + }; + }); +}; + +const computeClockCircleGuide = ( + viewBox: StorylineSize, + block: StorylineSize, + padding: StorylinePadding, + layout: IStorylineLayoutOptions +): StorylineCircleGuide => { + const innerWidth = Math.max(viewBox.width - padding.left - padding.right, 1); + const innerHeight = Math.max(viewBox.height - padding.top - padding.bottom, 1); + const center = { + x: padding.left + innerWidth / 2, + y: padding.top + innerHeight / 2 + }; + const ratio = layout.radiusRatio ?? 0.7; + const maxRadius = Math.max(Math.min(innerWidth - block.width, innerHeight - block.height) / 2, 1); + + return { + center, + radius: Math.max(1, maxRadius * ratio) + }; +}; + +const arcCenters = ( + count: number, + inner: { x: number; y: number; width: number; height: number }, + block: StorylineSize, + layout: IStorylineLayoutOptions, + fallbackStartAngle?: number, + fallbackEndAngle?: number, + defaultRatio = 0.88 +) => { + const startAngle = layout.startAngle ?? fallbackStartAngle ?? -90; + const endAngle = layout.endAngle ?? fallbackEndAngle ?? 270; + const ratio = layout.radiusRatio ?? defaultRatio; + const rx = Math.max((inner.width - block.width) / 2, 1) * ratio; + const ry = Math.max((inner.height - block.height) / 2, 1) * ratio; + const center = { + x: inner.x + inner.width / 2, + y: inner.y + inner.height / 2 + }; + + if (count === 1) { + const angle = degreeToRadian((startAngle + endAngle) / 2); + return [{ x: center.x + Math.cos(angle) * rx, y: center.y + Math.sin(angle) * ry }]; + } + + return Array.from({ length: count }, (_, index) => { + const t = index / (count - 1); + const angle = degreeToRadian(startAngle + angleDelta(startAngle, endAngle) * t); + return { + x: center.x + Math.cos(angle) * rx, + y: center.y + Math.sin(angle) * ry + }; + }); +}; + +const angleDelta = (startAngle: number, endAngle: number) => { + const delta = endAngle - startAngle; + return Math.abs(delta) >= 360 ? 360 : delta; +}; + +const degreeToRadian = (degree: number) => (degree / 180) * Math.PI; + +const computeLinks = (blocks: StorylineBlockPosition[], distance: number): StorylineLinkPosition[] => { + const links: StorylineLinkPosition[] = []; + for (let i = 0; i < blocks.length - 1; i++) { + const from = blocks[i]; + const to = blocks[i + 1]; + const start = pointOnBlockEdge(from, to.center, distance); + const end = pointOnBlockEdge(to, from.center, distance); + links.push({ + from, + to, + start, + end, + points: [start, end] + }); + } + return links; +}; + +const pointOnBlockEdge = (block: StorylineBlockPosition, toward: StorylinePoint, distance: number): StorylinePoint => { + const dx = toward.x - block.center.x; + const dy = toward.y - block.center.y; + if (dx === 0 && dy === 0) { + return { x: block.center.x, y: block.center.y }; + } + const scaleX = dx === 0 ? Number.POSITIVE_INFINITY : block.width / 2 / Math.abs(dx); + const scaleY = dy === 0 ? Number.POSITIVE_INFINITY : block.height / 2 / Math.abs(dy); + const scale = Math.min(scaleX, scaleY); + const length = Math.sqrt(dx * dx + dy * dy) || 1; + return { + x: block.center.x + dx * scale + (dx / length) * distance, + y: block.center.y + dy * scale + (dy / length) * distance + }; +}; diff --git a/packages/vchart-extension/src/charts/storyline/layouts/bowl.ts b/packages/vchart-extension/src/charts/storyline/layouts/bowl.ts new file mode 100644 index 0000000000..b69f8e70c4 --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/layouts/bowl.ts @@ -0,0 +1,425 @@ +import type { IExtensionGroupMarkSpec } from '@visactor/vchart'; +import { LayoutZIndex } from '@visactor/vchart'; +import type { IStorylineBlock, IStorylineSpec } from '../interface'; +import { + type ICustomMarkSpec, + type LayoutContext, + type StorylinePoint, + buildRichContent, + getRegionGeometry, + getThemeColor, + normalizeLayout, + normalizePadding, + omitImageLayoutSpec, + resolveBlockWidth, + withAlpha +} from './common'; + +// bowl 布局:dome 的上下镜像 +// - centerImage 贴顶(dome 是贴底) +// - 弧线在 centerImage 下方(dome 在上方) +// - block 沿弧线分布、image + title/content 位于弧线外侧(弧线下方) +// - title/content 位于 image 下方(dome 是上方) +// image 默认为圆形,BOWL_BLOCK_IMAGE_SIZE 即圆的直径 +const BOWL_BLOCK_IMAGE_SIZE = 140; +const BOWL_TEXT_GAP_FROM_IMAGE = 10; +const BOWL_TITLE_LINE_HEIGHT = 19; +const BOWL_CONTENT_LINE_HEIGHT = 17; +const BOWL_CONTENT_FONT_SIZE = 12; +// title + content 区域总高度(默认 300px,溢出由富文本 heightLimit + ellipsis 自动截断) +const BOWL_TEXT_BOX_HEIGHT = 300; +const BOWL_TITLE_TO_CONTENT_GAP = 4; +// 引导线与 title/content 之间的水平间距 +const BOWL_TEXT_LEFT_PADDING = 20; +const BOWL_CENTER_IMAGE_WIDTH_RATIO = 0.32; +const BOWL_CENTER_IMAGE_HEIGHT_RATIO = 0.32; +// 弧线最低点(视觉上的底点)距离 centerImage 底部的距离 +const BOWL_ARC_BOTTOM_GAP_FROM_CENTER_IMAGE = 300; + +/** + * 计算 bowl 布局 centerImage 的 box:水平居中、垂直贴顶(位于 inner 区域顶部)。 + */ +const getBowlCenterImageRect = (spec: IStorylineSpec, ctx: LayoutContext) => { + const { width, height, startX, startY } = getRegionGeometry(ctx); + const padding = normalizePadding(spec.block?.padding); + const innerWidth = Math.max(width - padding.left - padding.right, 1); + const innerHeight = Math.max(height - padding.top - padding.bottom, 1); + const w = Math.max(spec.centerImage?.width ?? innerWidth * BOWL_CENTER_IMAGE_WIDTH_RATIO, 80); + const h = Math.max(spec.centerImage?.height ?? innerHeight * BOWL_CENTER_IMAGE_HEIGHT_RATIO, 60); + const cx = startX + padding.left + innerWidth / 2; + // 紧贴顶部,仅保留 spec.block.padding.top 的留白 + const top = startY + padding.top; + return { x: cx - w / 2, y: top, width: w, height: h }; +}; + +/** + * 计算 bowl 弧线的几何参数: + * - cx / rx / startAngle / endAngle 与 layout.ts 中 arcCenters 一致; + * - cy 与 ry 由两条对齐约束反推: + * 1) 弧线起/终点 y == centerImage 顶部 + * 2) 弧线最低点 y == centerImage 底部 + BOWL_ARC_BOTTOM_GAP_FROM_CENTER_IMAGE + * + * bowl 的 startAngle = 20°、endAngle = 160°(弧线在 centerImage 下方)。 + * 解方程: + * cy + ry * sin(startAngle) = centerImageTop + * cy + ry = centerImageBottom + GAP + * → ry = (GAP + centerImageHeight) / (1 - sin(startAngle)) + * cy = centerImageTop - ry * sin(startAngle) + */ +const getBowlArcGeometry = (spec: IStorylineSpec, ctx: LayoutContext) => { + const { width, startX } = getRegionGeometry(ctx); + const blockPadding = normalizePadding(spec.block?.padding); + const innerWidth = Math.max(width - blockPadding.left - blockPadding.right, 1); + const blockWidth = resolveBlockWidth(spec, width); + const layoutOpt = normalizeLayout(spec.layout); + // bowl 默认弧线起止角与 layout.ts 中一致 + const startAngle = layoutOpt.startAngle ?? 20; + const endAngle = layoutOpt.endAngle ?? 160; + const ratio = layoutOpt.radiusRatio ?? 0.88; + const rx = Math.max((innerWidth - blockWidth) / 2, 1) * ratio; + const centerRect = getBowlCenterImageRect(spec, ctx); + const centerTop = centerRect.y; + const centerBottom = centerRect.y + centerRect.height; + const sinStart = Math.sin((startAngle / 180) * Math.PI); + // sinStart 接近 1 时 ry → ∞;这里限制下界以防 startAngle 配置异常 + const denom = Math.max(1 - sinStart, 0.05); + const ry = (centerRect.height + BOWL_ARC_BOTTOM_GAP_FROM_CENTER_IMAGE) / denom; + const cy = centerTop - ry * sinStart; + return { + cx: startX + blockPadding.left + innerWidth / 2, + cy, + rx, + ry, + startAngle, + endAngle, + centerTop, + centerBottom + }; +}; + +/** + * 在 bowl 弧线上按 index 采样 block 中心,与 arc 完全同步。 + * 同时让 block 沿弧线径向向外偏移 imageHeight/2, + * 使 image 内边贴在弧线上,image + text 整体位于弧线外侧(下方)。 + */ +const getBowlBlockCenter = (spec: IStorylineSpec, ctx: LayoutContext, index: number): StorylinePoint => { + const arc = getBowlArcGeometry(spec, ctx); + const count = spec.data?.length ?? 0; + if (count <= 0) { + return { x: arc.cx, y: arc.cy }; + } + const t = count === 1 ? 0.5 : index / (count - 1); + const angle = ((arc.startAngle + (arc.endAngle - arc.startAngle) * t) / 180) * Math.PI; + const px = arc.cx + Math.cos(angle) * arc.rx; + const py = arc.cy + Math.sin(angle) * arc.ry; + // 椭圆在 (px,py) 处的外法向量 ∝ (cos(angle)/rx, sin(angle)/ry) + const nxRaw = Math.cos(angle) / arc.rx; + const nyRaw = Math.sin(angle) / arc.ry; + const nLen = Math.hypot(nxRaw, nyRaw) || 1; + const nx = nxRaw / nLen; + const ny = nyRaw / nLen; + const imageHeight = spec.image?.height ?? BOWL_BLOCK_IMAGE_SIZE; + const offset = imageHeight / 2; + return { x: px + nx * offset, y: py + ny * offset }; +}; + +/** + * 贯穿所有 block 的弧线 mark(path 通过沿椭圆采样实现) + * 默认不展示,仅当用户在 spec.line.visible 显式置为 true 时才渲染。 + */ +export const buildBowlArcMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + if (spec.line?.visible !== true) { + return null; + } + const themeColor = getThemeColor(spec); + return { + type: 'group' as any, + name: 'storyline-bowl-arc', + zIndex: LayoutZIndex.Mark, + children: [ + { + type: 'path', + name: 'storyline-bowl-arc-path', + interactive: false, + style: { + stroke: themeColor, + lineWidth: 2, + lineCap: 'round', + fill: 'transparent', + fillOpacity: 0, + path: (_d: unknown, ctx: LayoutContext) => { + const arc = getBowlArcGeometry(spec, ctx); + const span = arc.endAngle - arc.startAngle; + const samples = 64; + const segments: string[] = []; + for (let i = 0; i <= samples; i++) { + const t = i / samples; + const angle = ((arc.startAngle + span * t) / 180) * Math.PI; + const x = arc.cx + Math.cos(angle) * arc.rx; + const y = arc.cy + Math.sin(angle) * arc.ry; + segments.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`); + } + return segments.join(' '); + } + } + } as ICustomMarkSpec<'path'> + ] + }; +}; + +export const buildBowlCenterImageMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + const visible = spec.centerImage?.visible !== false; + if (!visible) { + return null; + } + const themeColor = getThemeColor(spec); + const hasImage = !!spec.centerImage?.image; + // 主题色生成的线性渐变(顶部偏淡 → 底部主题色),作为 centerImage 位置的 symbol 填充 + const symbolGradient = { + gradient: 'linear', + x0: 0.5, + y0: 0, + x1: 0.5, + y1: 1, + stops: [ + { offset: 0, color: withAlpha(themeColor, 0.15) }, + { offset: 1, color: themeColor } + ] + }; + return { + type: 'group' as any, + name: 'storyline-bowl-center', + zIndex: LayoutZIndex.Mark, + children: [ + { + type: 'symbol', + name: 'storyline-bowl-center-symbol', + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => { + const r = getBowlCenterImageRect(spec, ctx); + return r.x + r.width / 2; + }, + y: (_d: unknown, ctx: LayoutContext) => { + const r = getBowlCenterImageRect(spec, ctx); + return r.y + r.height / 2; + }, + size: (_d: unknown, ctx: LayoutContext) => { + const r = getBowlCenterImageRect(spec, ctx); + return Math.max(r.width, r.height) * 1.1; + }, + symbolType: 'circle', + fill: symbolGradient, + stroke: themeColor, + lineWidth: 2 + } + } as ICustomMarkSpec<'symbol'>, + { + type: 'rect', + name: 'storyline-bowl-center-rect', + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => getBowlCenterImageRect(spec, ctx).x, + y: (_d: unknown, ctx: LayoutContext) => getBowlCenterImageRect(spec, ctx).y, + width: (_d: unknown, ctx: LayoutContext) => getBowlCenterImageRect(spec, ctx).width, + height: (_d: unknown, ctx: LayoutContext) => getBowlCenterImageRect(spec, ctx).height, + cornerRadius: 12, + fill: '#ffffff', + stroke: themeColor, + lineWidth: 2 + } + } as ICustomMarkSpec<'rect'>, + hasImage + ? ({ + type: 'image', + name: 'storyline-bowl-center-image', + interactive: false, + ...spec.centerImage, + style: { + x: (_d: unknown, ctx: LayoutContext) => getBowlCenterImageRect(spec, ctx).x, + y: (_d: unknown, ctx: LayoutContext) => getBowlCenterImageRect(spec, ctx).y, + width: (_d: unknown, ctx: LayoutContext) => getBowlCenterImageRect(spec, ctx).width, + height: (_d: unknown, ctx: LayoutContext) => getBowlCenterImageRect(spec, ctx).height, + image: spec.centerImage?.image, + cornerRadius: 12, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'cover', + imagePosition: 'center', + ...spec.centerImage?.style + } + } as ICustomMarkSpec<'image'>) + : null + ].filter(Boolean) as ICustomMarkSpec[] + }; +}; + +const getBowlBlockMetrics = (spec: IStorylineSpec) => { + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 14); + const titleLineHeight = Number( + (spec.title?.style as any)?.lineHeight ?? Math.max(BOWL_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.35)) + ); + const contentFontSize = Number((spec.content?.style as any)?.fontSize ?? BOWL_CONTENT_FONT_SIZE); + const contentLineHeight = Number((spec.content?.style as any)?.lineHeight ?? BOWL_CONTENT_LINE_HEIGHT); + const titleToContentGap = BOWL_TITLE_TO_CONTENT_GAP; + const textHeight = BOWL_TEXT_BOX_HEIGHT; + const contentHeight = Math.max(textHeight - titleLineHeight - titleToContentGap, contentLineHeight); + + const imageWidth = spec.image?.width ?? BOWL_BLOCK_IMAGE_SIZE; + const imageHeight = spec.image?.height ?? BOWL_BLOCK_IMAGE_SIZE; + + // image 位于 block 中心,title/content 在 image 下方(与 dome 上下对称) + const imageBox = { + x: -imageWidth / 2, + y: -imageHeight / 2, + width: imageWidth, + height: imageHeight + }; + const textBox = { + x: -imageWidth / 2 + BOWL_TEXT_LEFT_PADDING, + y: imageBox.y + imageHeight + BOWL_TEXT_GAP_FROM_IMAGE, + width: imageWidth - BOWL_TEXT_LEFT_PADDING, + height: textHeight + }; + const contentBox = { + x: textBox.x, + y: textBox.y + titleLineHeight + titleToContentGap, + width: textBox.width, + height: contentHeight + }; + return { + titleFontSize, + titleLineHeight, + contentFontSize, + contentLineHeight, + imageBox, + textBox, + contentBox + }; +}; + +export const buildBowlBlockMark = ( + spec: IStorylineSpec, + block: IStorylineBlock, + index: number +): IExtensionGroupMarkSpec => { + const hasImage = !!block.image; + const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; + const themeColor = getThemeColor(spec); + const metrics = getBowlBlockMetrics(spec); + + return { + type: 'group' as any, + id: `storyline-block-${block.id ?? index}`, + name: `storyline-block-${index}`, + zIndex: LayoutZIndex.Mark + 1, + style: { + x: (_d: unknown, ctx: LayoutContext) => getBowlBlockCenter(spec, ctx, index).x, + y: (_d: unknown, ctx: LayoutContext) => getBowlBlockCenter(spec, ctx, index).y + }, + children: [ + // title / content 左侧的垂直引导线(贯穿 image 底部 → text 底部,与文字保持 padding) + { + type: 'rect', + name: `storyline-block-connector-${index}`, + interactive: false, + style: { + x: metrics.imageBox.x, + y: metrics.imageBox.y + metrics.imageBox.height, + width: 2, + height: Math.max( + metrics.textBox.y + metrics.textBox.height - (metrics.imageBox.y + metrics.imageBox.height), + 0 + ), + fill: themeColor, + fillOpacity: 0.6 + } + } as ICustomMarkSpec<'rect'>, + hasImage + ? ({ + type: 'image', + name: `storyline-block-image-${index}`, + interactive: false, + ...omitImageLayoutSpec(spec.image), + style: { + x: metrics.imageBox.x, + y: metrics.imageBox.y, + width: metrics.imageBox.width, + height: metrics.imageBox.height, + image: block.image, + // 圆形裁剪:cornerRadius = min(w,h) / 2 + cornerRadius: Math.min(metrics.imageBox.width, metrics.imageBox.height) / 2, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'cover', + imagePosition: 'center', + stroke: themeColor, + lineWidth: 2, + ...spec.image?.style + } + } as ICustomMarkSpec<'image'>) + : ({ + type: 'rect', + name: `storyline-block-image-bg-${index}`, + interactive: false, + style: { + x: metrics.imageBox.x, + y: metrics.imageBox.y, + width: metrics.imageBox.width, + height: metrics.imageBox.height, + cornerRadius: Math.min(metrics.imageBox.width, metrics.imageBox.height) / 2, + fill: '#ffffff', + stroke: themeColor, + lineWidth: 2 + } + } as ICustomMarkSpec<'rect'>), + block.title + ? ({ + type: 'text', + name: `storyline-block-title-${index}`, + interactive: false, + ...spec.title, + style: { + x: metrics.textBox.x, + y: metrics.textBox.y, + text: block.title, + maxLineWidth: metrics.textBox.width, + fontSize: metrics.titleFontSize, + lineHeight: metrics.titleLineHeight, + fontWeight: 'bold', + fill: '#1f2430', + textAlign: 'left', + textBaseline: 'top', + ...spec.title?.style + } + } as ICustomMarkSpec<'text'>) + : null, + contentText.length + ? ({ + type: 'text', + name: `storyline-block-content-${index}`, + interactive: false, + ...spec.content, + textType: 'rich', + style: { + x: metrics.contentBox.x, + y: metrics.contentBox.y, + width: metrics.contentBox.width, + height: metrics.contentBox.height, + maxLineWidth: metrics.contentBox.width, + heightLimit: metrics.contentBox.height, + text: buildRichContent(contentText, spec), + fontSize: BOWL_CONTENT_FONT_SIZE, + lineHeight: BOWL_CONTENT_LINE_HEIGHT, + textAlign: 'left', + textBaseline: 'top', + wordBreak: 'break-word', + ellipsis: '...', + fill: '#596173', + ...spec.content?.style + } + } as ICustomMarkSpec<'text'>) + : null + ].filter(Boolean) as ICustomMarkSpec[] + }; +}; diff --git a/packages/vchart-extension/src/charts/storyline/layouts/clock.ts b/packages/vchart-extension/src/charts/storyline/layouts/clock.ts new file mode 100644 index 0000000000..11c0c6fe52 --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/layouts/clock.ts @@ -0,0 +1,385 @@ +import type { IExtensionGroupMarkSpec } from '@visactor/vchart'; +import { LayoutZIndex } from '@visactor/vchart'; +import type { IStorylineBlock, IStorylineSpec } from '../interface'; +import { + type ICustomMarkSpec, + type LayoutContext, + buildRichContent, + getRegionGeometry, + getThemeColor, + normalizePadding, + withAlpha +} from './common'; + +/** + * clock 布局:环绕式时间线(orbit timeline) + * + * 视觉结构(参考报刊版式): + * + * ┌──────── 外侧文字段(title + content)─────────┐ + * │ │ + * ●●● │ ┌───── 虚线轨道圆环 ─────┐ │ + * 圆形 dot ──────引线──────┤ │ + * │ │ ◎ centerImage │ + * │ │ (大圆人像) │ + * │ └───────────────────────┘ + * └─────────────────────────────────────────────┘ + * + * - centerImage:圆形大图,位于版面中心 + * - 虚线轨道:紧贴 centerImage 外侧的圆环,提供时间线的视觉骨架 + * - 每个 block 由 1 个圆形小图(dot)压在轨道上,外加一段从 dot 引出的 title + content 文字 + * - block 沿轨道环绕分布(默认 360°);左半圆 block 文字 right-align,右半圆 block 文字 left-align + */ + +// ===== 半径配置(按可用半径的比例划分各圈层)===== +const CLOCK_CENTER_RADIUS_RATIO = 0.5; // 中心圆半径 +const CLOCK_CENTER_IMAGE_INSET_RATIO = 0.86; // centerImage 相对中心圆的尺寸比例(留出环形空隙) +const CLOCK_ORBIT_RATIO = 0.58; // 虚线轨道半径 +const CLOCK_DOT_RATIO = 0.58; // 圆形小图(dot)中心所在半径(与轨道重合) +const CLOCK_TEXT_INNER_RATIO = 0.7; // block 文字段起始半径 +const CLOCK_TEXT_MAX_WIDTH = 200; // 文字段最大宽度,避免靠近正上/正下的 block 占满整个画布半宽 + +// ===== 元素尺寸 ===== +const CLOCK_DOT_DIAMETER_RATIO = 0.24; // dot 直径相对 R +const CLOCK_LEAD_LINE_GAP = 6; // dot 到引线起点的间距 px +const CLOCK_TEXT_GAP_FROM_LEAD = 8; // 引线到文字的间距 px +const CLOCK_ORBIT_DASH = [4, 4]; + +// ===== 文字 ===== +const CLOCK_TITLE_FONT_SIZE = 13; +const CLOCK_TITLE_LINE_HEIGHT = 18; +const CLOCK_CONTENT_FONT_SIZE = 11; +const CLOCK_CONTENT_LINE_HEIGHT = 15; + +// ===== 几何 ===== + +type ClockGeometry = { + cx: number; + cy: number; + R: number; // 整盘外半径 + count: number; + step: number; +}; + +const getClockGeometry = (spec: IStorylineSpec, ctx: LayoutContext): ClockGeometry => { + const { width, height, startX, startY } = getRegionGeometry(ctx); + const padding = normalizePadding(spec.block?.padding); + const innerWidth = Math.max(width - padding.left - padding.right, 1); + const innerHeight = Math.max(height - padding.top - padding.bottom, 1); + const cx = startX + padding.left + innerWidth / 2; + const cy = startY + padding.top + innerHeight / 2; + const R = Math.max(Math.min(innerWidth, innerHeight) / 2, 1); + const count = spec.data?.length ?? 0; + const step = count > 0 ? (Math.PI * 2) / count : 0; + return { cx, cy, R, count, step }; +}; + +/** + * 第 index 个 block 在轨道上的角度。 + * - 0° = 正上方(12 点钟) + * - 顺时针递增 + */ +const getClockBlockAngle = (geom: ClockGeometry, index: number) => -Math.PI / 2 + geom.step * (index + 0.5); + +const polar = (cx: number, cy: number, r: number, angle: number) => ({ + x: cx + Math.cos(angle) * r, + y: cy + Math.sin(angle) * r +}); + +/** + * 判断 block 在版面左半边还是右半边。 + * 用于决定 block 文字的对齐方向(左半边 right-align,右半边 left-align)。 + */ +const isOnLeftHalf = (angle: number) => Math.cos(angle) < 0; + +// ===== 中心圆 ===== + +export const buildClockCenterImageMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + if (spec.centerImage?.visible === false) { + return null; + } + const themeColor = getThemeColor(spec); + const hasImage = !!spec.centerImage?.image; + return { + type: 'group' as any, + name: 'storyline-clock-center', + zIndex: LayoutZIndex.Mark + 2, + children: [ + // centerImage 背后的高亮光晕(主题色透明色,营造"焦点"效果) + { + type: 'symbol', + name: 'storyline-clock-center-halo', + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => getClockGeometry(spec, ctx).cx, + y: (_d: unknown, ctx: LayoutContext) => getClockGeometry(spec, ctx).cy, + size: (_d: unknown, ctx: LayoutContext) => { + const g = getClockGeometry(spec, ctx); + return g.R * CLOCK_CENTER_RADIUS_RATIO * 2.16; + }, + symbolType: 'circle', + fill: withAlpha(themeColor, 0.28), + stroke: 'transparent' + } + } as ICustomMarkSpec<'symbol'>, + hasImage + ? ({ + type: 'image', + name: 'storyline-clock-center-image', + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => { + const g = getClockGeometry(spec, ctx); + return g.cx - g.R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO; + }, + y: (_d: unknown, ctx: LayoutContext) => { + const g = getClockGeometry(spec, ctx); + return g.cy - g.R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO; + }, + width: (_d: unknown, ctx: LayoutContext) => + getClockGeometry(spec, ctx).R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO * 2, + height: (_d: unknown, ctx: LayoutContext) => + getClockGeometry(spec, ctx).R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO * 2, + image: spec.centerImage?.image, + cornerRadius: (_d: unknown, ctx: LayoutContext) => + getClockGeometry(spec, ctx).R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'cover', + imagePosition: 'center', + ...spec.centerImage?.style + } + } as ICustomMarkSpec<'image'>) + : ({ + type: 'symbol', + name: 'storyline-clock-center-placeholder', + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => getClockGeometry(spec, ctx).cx, + y: (_d: unknown, ctx: LayoutContext) => getClockGeometry(spec, ctx).cy, + size: (_d: unknown, ctx: LayoutContext) => getClockGeometry(spec, ctx).R * CLOCK_CENTER_RADIUS_RATIO * 2, + symbolType: 'circle', + fill: '#ffffff', + stroke: themeColor, + lineWidth: 2 + } + } as ICustomMarkSpec<'symbol'>) + ].filter(Boolean) as ICustomMarkSpec[] + }; +}; + +// ===== 虚线轨道 ===== + +/** + * 紧贴 centerImage 外侧的虚线圆环轨道。 + */ +export const buildClockArcMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + const themeColor = getThemeColor(spec); + + const orbitPath = (_d: unknown, ctx: LayoutContext) => { + const g = getClockGeometry(spec, ctx); + const r = g.R * CLOCK_ORBIT_RATIO; + return [ + `M ${(g.cx + r).toFixed(2)} ${g.cy.toFixed(2)}`, + `A ${r.toFixed(2)} ${r.toFixed(2)} 0 1 1 ${(g.cx - r).toFixed(2)} ${g.cy.toFixed(2)}`, + `A ${r.toFixed(2)} ${r.toFixed(2)} 0 1 1 ${(g.cx + r).toFixed(2)} ${g.cy.toFixed(2)}` + ].join(' '); + }; + + return { + type: 'group' as any, + name: 'storyline-clock-orbit', + zIndex: LayoutZIndex.Mark, + children: [ + { + type: 'path', + name: 'storyline-clock-orbit-path', + interactive: false, + style: { + path: orbitPath, + stroke: withAlpha(themeColor, 0.7), + lineWidth: 1, + lineDash: CLOCK_ORBIT_DASH, + fill: 'transparent', + fillOpacity: 0 + } + } as ICustomMarkSpec<'path'> + ] + }; +}; + +// ===== block:dot + 引线 + 文字段 ===== + +const getClockDotCenter = (spec: IStorylineSpec, ctx: LayoutContext, index: number) => { + const g = getClockGeometry(spec, ctx); + const angle = getClockBlockAngle(g, index); + const r = g.R * CLOCK_DOT_RATIO; + return { ...polar(g.cx, g.cy, r, angle), diameter: g.R * CLOCK_DOT_DIAMETER_RATIO, angle }; +}; + +/** + * 引线(dot 外缘 → 文字段内边)的两个端点。 + */ +const getClockLeadLine = (spec: IStorylineSpec, ctx: LayoutContext, index: number) => { + const g = getClockGeometry(spec, ctx); + const angle = getClockBlockAngle(g, index); + const dotR = (g.R * CLOCK_DOT_DIAMETER_RATIO) / 2; + const start = polar(g.cx, g.cy, g.R * CLOCK_DOT_RATIO + dotR + CLOCK_LEAD_LINE_GAP, angle); + const end = polar(g.cx, g.cy, g.R * CLOCK_TEXT_INNER_RATIO - CLOCK_TEXT_GAP_FROM_LEAD, angle); + return { start, end }; +}; + +/** + * block 文字段的矩形(中心 + 宽高 + 对齐)。 + * 文字段沿水平方向从 dot 一侧外延: + * - 左半圆:文字右对齐,向左延伸至画布左边界(含 padding) + * - 右半圆:文字左对齐,向右延伸至画布右边界(含 padding) + * 这样所有 block 的可用宽度都是一致的"画布半宽 - 中心圆半径",避免出现窄文字。 + */ +const getClockTextRect = (spec: IStorylineSpec, ctx: LayoutContext, index: number) => { + const g = getClockGeometry(spec, ctx); + const { width: regionWidth, startX } = getRegionGeometry(ctx); + const padding = normalizePadding(spec.block?.padding); + const angle = getClockBlockAngle(g, index); + const onLeft = isOnLeftHalf(angle); + // 文字段从 dot 外侧的 inner ring 处开始水平延展 + const rInner = g.R * CLOCK_TEXT_INNER_RATIO; + const innerPoint = polar(g.cx, g.cy, rInner, angle); + // 画布水平边界(含 padding) + const leftEdge = startX + padding.left; + const rightEdge = startX + regionWidth - padding.right; + const width = onLeft + ? Math.min(Math.max(innerPoint.x - leftEdge, 80), CLOCK_TEXT_MAX_WIDTH) + : Math.min(Math.max(rightEdge - innerPoint.x, 80), CLOCK_TEXT_MAX_WIDTH); + return { x: innerPoint.x, y: innerPoint.y, width, onLeft, anchorY: innerPoint.y }; +}; + +export const buildClockBlockMark = ( + spec: IStorylineSpec, + block: IStorylineBlock, + index: number +): IExtensionGroupMarkSpec => { + const hasImage = !!block.image; + const themeColor = getThemeColor(spec); + const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; + + const leadPath = (_d: unknown, ctx: LayoutContext) => { + const { start, end } = getClockLeadLine(spec, ctx, index); + return `M ${start.x.toFixed(2)} ${start.y.toFixed(2)} L ${end.x.toFixed(2)} ${end.y.toFixed(2)}`; + }; + + const children: (ICustomMarkSpec | null)[] = [ + // 引线:从 dot 外缘到文字段内边 + { + type: 'path', + name: `storyline-clock-lead-${index}`, + interactive: false, + style: { + path: leadPath, + stroke: withAlpha(themeColor, 0.7), + lineWidth: 1, + lineDash: [3, 3], + fill: 'transparent', + fillOpacity: 0 + } + } as ICustomMarkSpec<'path'>, + // 圆形小图(dot):压在轨道上,作为时间锚点 + hasImage + ? ({ + type: 'image', + name: `storyline-clock-dot-${index}`, + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => { + const dot = getClockDotCenter(spec, ctx, index); + return dot.x - dot.diameter / 2; + }, + y: (_d: unknown, ctx: LayoutContext) => { + const dot = getClockDotCenter(spec, ctx, index); + return dot.y - dot.diameter / 2; + }, + width: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).diameter, + height: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).diameter, + image: block.image, + cornerRadius: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).diameter / 2, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'cover', + imagePosition: 'center', + stroke: themeColor, + lineWidth: 2 + } + } as ICustomMarkSpec<'image'>) + : ({ + type: 'symbol', + name: `storyline-clock-dot-${index}`, + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).x, + y: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).y, + size: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).diameter, + symbolType: 'circle', + fill: themeColor, + stroke: '#ffffff', + lineWidth: 1.5 + } + } as ICustomMarkSpec<'symbol'>), + // title:文字段的第一行 + block.title + ? ({ + type: 'text', + name: `storyline-clock-title-${index}`, + interactive: false, + ...spec.title, + style: { + x: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).x, + y: (_d: unknown, ctx: LayoutContext) => + getClockTextRect(spec, ctx, index).anchorY - CLOCK_TITLE_LINE_HEIGHT, + text: block.title, + maxLineWidth: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).width, + fontSize: CLOCK_TITLE_FONT_SIZE, + lineHeight: CLOCK_TITLE_LINE_HEIGHT, + fontWeight: 'bold', + fill: themeColor, + textAlign: (_d: unknown, ctx: LayoutContext) => + getClockTextRect(spec, ctx, index).onLeft ? 'right' : 'left', + textBaseline: 'top', + ...spec.title?.style + } + } as ICustomMarkSpec<'text'>) + : null, + // content:富文本,title 下方 + contentText.length + ? ({ + type: 'text', + name: `storyline-clock-content-${index}`, + interactive: false, + ...spec.content, + textType: 'rich', + style: { + x: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).x, + y: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).anchorY + 4, + width: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).width, + maxLineWidth: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).width, + text: buildRichContent(contentText, spec), + fontSize: CLOCK_CONTENT_FONT_SIZE, + lineHeight: CLOCK_CONTENT_LINE_HEIGHT, + fill: '#3a3f4d', + textAlign: (_d: unknown, ctx: LayoutContext) => + getClockTextRect(spec, ctx, index).onLeft ? 'right' : 'left', + textBaseline: 'top', + wordBreak: 'break-word', + ...spec.content?.style + } + } as ICustomMarkSpec<'text'>) + : null + ]; + + return { + type: 'group' as any, + id: `storyline-block-${block.id ?? index}`, + name: `storyline-block-${index}`, + zIndex: LayoutZIndex.Mark + 1, + children: children.filter(Boolean) as ICustomMarkSpec[] + }; +}; diff --git a/packages/vchart-extension/src/charts/storyline/layouts/common.ts b/packages/vchart-extension/src/charts/storyline/layouts/common.ts new file mode 100644 index 0000000000..e295df9c7c --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/layouts/common.ts @@ -0,0 +1,311 @@ +import type { ICustomMarkSpec } from '@visactor/vchart'; +import type { IStorylineBlock, IStorylineSpec, StorylineImagePosition } from '../interface'; +import { + computeStorylineLayout, + normalizeLayout, + normalizePadding, + type StorylineLayoutResult, + type StorylinePoint +} from '../layout'; + +// ===== 布局通用类型 ===== + +export type LayoutContext = { + chart?: { + getAllRegions?: () => { + getLayoutRect?: () => { width?: number; height?: number }; + getLayoutStartPoint?: () => { x?: number; y?: number }; + }[]; + getLayoutRect?: () => { width?: number; height?: number }; + }; + getLayoutBounds?: () => { width?: () => number; height?: () => number }; +}; + +// ===== 通用默认值 ===== + +export const DEFAULT_BLOCK_WIDTH = 180; +export const DEFAULT_BLOCK_HEIGHT = 112; +export const DEFAULT_BLOCK_WIDTH_RATIO = 0.24; +export const DEFAULT_BLOCK_GAP = 36; +export const DEFAULT_IMAGE_WIDTH = 48; +export const DEFAULT_IMAGE_HEIGHT = 48; +export const DEFAULT_IMAGE_GAP = 10; +export const DEFAULT_THEME_COLOR = '#e8543d'; + +// ===== 布局判定 ===== + +export const isLandscape = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'landscape'; +export const isPortrait = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'portrait'; +export const isClock = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'clock'; +export const isDome = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'dome'; +export const isBowl = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'bowl'; + +export const getThemeColor = (spec: IStorylineSpec) => spec.themeColor ?? DEFAULT_THEME_COLOR; + +// ===== 颜色工具 ===== + +/** + * 给颜色(#hex / rgb / rgba / hsl / 颜色关键字)追加/替换 alpha 通道,返回 rgba(...) 字符串 + */ +export const withAlpha = (color: string, alpha: number): string => { + const safeAlpha = Math.max(0, Math.min(1, alpha)); + if (!color) { + return `rgba(0, 0, 0, ${safeAlpha})`; + } + const trimmed = color.trim(); + if (trimmed.startsWith('#')) { + let hex = trimmed.slice(1); + if (hex.length === 3 || hex.length === 4) { + hex = hex + .split('') + .map(ch => ch + ch) + .join(''); + } + if (hex.length === 6 || hex.length === 8) { + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${safeAlpha})`; + } + } + const rgbMatch = trimmed.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i); + if (rgbMatch) { + return `rgba(${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}, ${safeAlpha})`; + } + return trimmed; +}; + +// ===== 块宽度解析 ===== + +export const resolveBlockWidth = (spec: IStorylineSpec, viewWidth: number) => { + if (spec.block?.width) { + return spec.block.width; + } + const ratio = spec.block?.widthRatio ?? DEFAULT_BLOCK_WIDTH_RATIO; + const minWidth = spec.block?.minWidth ?? DEFAULT_BLOCK_WIDTH; + const maxWidth = spec.block?.maxWidth ?? Math.max(minWidth, 320); + return Math.max(minWidth, Math.min(maxWidth, Math.round(viewWidth * ratio))); +}; + +// ===== 容器几何信息(chart region rect)===== + +export const getRegionGeometry = (ctx: LayoutContext) => { + const region = ctx.chart?.getAllRegions?.()?.[0]; + const regionRect = region?.getLayoutRect?.(); + const regionStart = region?.getLayoutStartPoint?.(); + const chartRect = ctx.chart?.getLayoutRect?.(); + const bounds = ctx.getLayoutBounds?.(); + const width = Math.max(regionRect?.width ?? chartRect?.width ?? bounds?.width?.() ?? 0, 1); + const height = Math.max(regionRect?.height ?? chartRect?.height ?? bounds?.height?.() ?? 0, 1); + return { + width, + height, + startX: regionStart?.x ?? 0, + startY: regionStart?.y ?? 0 + }; +}; + +// ===== 布局计算(layout.ts 的封装,附加 startX/startY 平移)===== + +export const getLayout = (spec: IStorylineSpec, ctx: LayoutContext): StorylineLayoutResult => { + const { width, height, startX, startY } = getRegionGeometry(ctx); + let blockWidth = resolveBlockWidth(spec, width); + let blockHeight = spec.block?.height ?? DEFAULT_BLOCK_HEIGHT; + // landscape:图片间距固定 40,根据 block 数量自适应单个 image 宽度 + if (isLandscape(spec) && !spec.block?.width) { + const count = spec.data?.length ?? 0; + if (count > 0) { + const padding = normalizePadding(spec.block?.padding); + const innerWidth = Math.max(width - padding.left - padding.right, 1); + const LANDSCAPE_IMAGE_GAP = 40; + const LANDSCAPE_IMAGE_MIN_WIDTH = 80; + const totalGap = LANDSCAPE_IMAGE_GAP * Math.max(count - 1, 0); + const adaptive = (innerWidth - totalGap) / count; + blockWidth = Math.max(LANDSCAPE_IMAGE_MIN_WIDTH, Math.floor(adaptive)); + } + } + // portrait:每个 block 在垂直方向需要容纳 image + text,整体根据 viewBox 高度均分 + if (isPortrait(spec) && !spec.block?.height) { + const count = spec.data?.length ?? 0; + if (count > 0) { + const padding = normalizePadding(spec.block?.padding); + const innerHeight = Math.max(height - padding.top - padding.bottom, 1); + blockHeight = Math.max(160, Math.floor(innerHeight / count)); + } + } + const result = computeStorylineLayout(spec.data ?? [], { + layout: spec.layout, + viewBox: { width, height }, + block: { + width: blockWidth, + height: blockHeight + }, + gap: spec.block?.gap ?? DEFAULT_BLOCK_GAP, + padding: spec.block?.padding, + lineDistance: spec.line?.distance + }); + if (!startX && !startY) { + return result; + } + return { + ...result, + blocks: result.blocks.map(block => ({ + ...block, + x: block.x + startX, + y: block.y + startY, + center: { + x: block.center.x + startX, + y: block.center.y + startY + } + })), + links: result.links.map(link => ({ + ...link, + start: { x: link.start.x + startX, y: link.start.y + startY }, + end: { x: link.end.x + startX, y: link.end.y + startY }, + points: link.points.map(point => ({ x: point.x + startX, y: point.y + startY })) + })) + }; +}; + +// ===== 文本 / 图像通用工具 ===== + +export const buildRichContent = (contentText: string[], spec: IStorylineSpec) => { + const fontSize = Number((spec.content?.style as any)?.fontSize ?? 12); + const lineHeight = Number((spec.content?.style as any)?.lineHeight ?? 18); + const fill = (spec.content?.style as any)?.fill ?? '#596173'; + + return { + type: 'rich' as const, + text: contentText.reduce<{ text: string; fontSize: number; lineHeight: number; fill: string }[]>( + (result, paragraph, index) => { + const suffix = index === contentText.length - 1 ? '' : '\n'; + result.push({ + text: `${paragraph}${suffix}`, + fontSize, + lineHeight, + fill + }); + return result; + }, + [] + ) + }; +}; + +export const omitImageLayoutSpec = (imageSpec: IStorylineSpec['image']) => { + if (!imageSpec) { + return {}; + } + const { width: _width, height: _height, position: _position, gap: _gap, ...rest } = imageSpec; + return rest; +}; + +// ===== 默认 image / text 盒计算(用于通用 block)===== + +export const getImageBox = ( + position: StorylineImagePosition, + blockWidth: number, + blockHeight: number, + padding: ReturnType, + width: number, + height: number, + _gap: number, + visible: boolean +) => { + if (!visible) { + return { x: padding.left, y: padding.top, width: 0, height: 0 }; + } + switch (position) { + case 'left': + return { x: padding.left, y: (blockHeight - height) / 2, width, height }; + case 'right': + return { x: blockWidth - padding.right - width, y: (blockHeight - height) / 2, width, height }; + case 'bottom': + return { x: (blockWidth - width) / 2, y: blockHeight - padding.bottom - height, width, height }; + case 'top': + default: + return { x: (blockWidth - width) / 2, y: padding.top, width, height }; + } +}; + +export const getTextBox = ( + position: StorylineImagePosition, + blockWidth: number, + blockHeight: number, + padding: ReturnType, + imageWidth: number, + imageHeight: number, + imageGap: number, + hasImage: boolean +) => { + if (!hasImage) { + return { + x: padding.left, + y: padding.top, + width: blockWidth - padding.left - padding.right, + height: blockHeight - padding.top - padding.bottom + }; + } + switch (position) { + case 'left': + return { + x: padding.left + imageWidth + imageGap, + y: padding.top, + width: blockWidth - padding.left - padding.right - imageWidth - imageGap, + height: blockHeight - padding.top - padding.bottom + }; + case 'right': + return { + x: padding.left, + y: padding.top, + width: blockWidth - padding.left - padding.right - imageWidth - imageGap, + height: blockHeight - padding.top - padding.bottom + }; + case 'bottom': + return { + x: padding.left, + y: padding.top, + width: blockWidth - padding.left - padding.right, + height: blockHeight - padding.top - padding.bottom - imageHeight - imageGap + }; + case 'top': + default: + return { + x: padding.left, + y: padding.top + imageHeight + imageGap, + width: blockWidth - padding.left - padding.right, + height: blockHeight - padding.top - padding.bottom - imageHeight - imageGap + }; + } +}; + +// ===== Catmull-Rom 平滑曲线 ===== + +/** + * 用 Catmull-Rom 转 cubic Bezier 生成平滑曲线 path(贯穿所有点)。 + */ +export const buildSmoothCurvePath = (points: StorylinePoint[]): string => { + if (points.length < 2) { + return ''; + } + if (points.length === 2) { + return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`; + } + let d = `M ${points[0].x} ${points[0].y}`; + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[i - 1] ?? points[i]; + const p1 = points[i]; + const p2 = points[i + 1]; + const p3 = points[i + 2] ?? p2; + const c1x = p1.x + (p2.x - p0.x) / 6; + const c1y = p1.y + (p2.y - p0.y) / 6; + const c2x = p2.x - (p3.x - p1.x) / 6; + const c2y = p2.y - (p3.y - p1.y) / 6; + d += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`; + } + return d; +}; + +// 重导出常用的 layout helper(避免外部再 import layout.ts) +export { normalizeLayout, normalizePadding }; +export type { IStorylineBlock, ICustomMarkSpec, StorylinePoint }; diff --git a/packages/vchart-extension/src/charts/storyline/layouts/default.ts b/packages/vchart-extension/src/charts/storyline/layouts/default.ts new file mode 100644 index 0000000000..be312746e5 --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/layouts/default.ts @@ -0,0 +1,262 @@ +import type { IExtensionGroupMarkSpec } from '@visactor/vchart'; +import { LayoutZIndex } from '@visactor/vchart'; +import type { IStorylineBlock, IStorylineSpec, StorylineLineType } from '../interface'; +import { + type ICustomMarkSpec, + type LayoutContext, + type StorylinePoint, + DEFAULT_BLOCK_HEIGHT, + DEFAULT_IMAGE_WIDTH, + DEFAULT_IMAGE_HEIGHT, + DEFAULT_IMAGE_GAP, + buildRichContent, + getImageBox, + getLayout, + getTextBox, + normalizePadding, + omitImageLayoutSpec, + resolveBlockWidth +} from './common'; + +/** + * 默认布局:rect block(image + title + content) + 普通 link mark。 + */ + +export const buildDefaultLineMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + if (spec.line?.visible === false || (spec.data?.length ?? 0) <= 1) { + return null; + } + + return { + type: 'group' as any, + name: 'storyline-links', + zIndex: LayoutZIndex.Mark, + children: (spec.data ?? []).slice(1).map((_, index) => { + const { style = {}, type = 'line', showArrow = false, arrowSize = 8, ...rest } = spec.line ?? {}; + return { + type: 'path', + name: `storyline-link-${index}`, + interactive: false, + ...rest, + style: { + stroke: '#8a94a6', + lineWidth: 1.5, + fill: 'transparent', + fillOpacity: 0, + ...style, + path: (_datum: unknown, ctx: LayoutContext) => { + const link = getLayout(spec, ctx).links[index]; + if (!link) { + return ''; + } + return buildLinkPath(link.points, type, showArrow, arrowSize); + } + } + } as ICustomMarkSpec<'path'>; + }) + }; +}; + +const getDefaultBlockMetrics = (spec: IStorylineSpec, ctx: LayoutContext, index: number) => { + const block = getLayout(spec, ctx).blocks[index]; + const padding = normalizePadding(spec.block?.padding ?? 12); + const imagePosition = spec.image?.position ?? 'top'; + const imageWidth = spec.image?.width ?? DEFAULT_IMAGE_WIDTH; + const imageHeight = spec.image?.height ?? DEFAULT_IMAGE_HEIGHT; + const imageGap = spec.image?.gap ?? DEFAULT_IMAGE_GAP; + const hasImage = !!spec.data?.[index]?.image; + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 14); + const titleLineHeight = Number((spec.title?.style as any)?.lineHeight ?? Math.round(titleFontSize * 1.35)); + const titleHeight = spec.data?.[index]?.title ? titleLineHeight : 0; + const blockWidth = block?.width ?? resolveBlockWidth(spec, 0); + const blockHeight = block?.height ?? spec.block?.height ?? DEFAULT_BLOCK_HEIGHT; + const imageBox = getImageBox( + imagePosition, + blockWidth, + blockHeight, + padding, + imageWidth, + imageHeight, + imageGap, + hasImage + ); + const textBox = getTextBox( + imagePosition, + blockWidth, + blockHeight, + padding, + imageWidth, + imageHeight, + imageGap, + hasImage + ); + const contentGap = spec.data?.[index]?.title ? 8 : 0; + + return { + block: { + width: blockWidth, + height: blockHeight + }, + imageBox, + textBox, + contentBox: { + y: textBox.y + titleHeight + contentGap, + height: Math.max(0, textBox.height - titleHeight - contentGap) + } + }; +}; + +export const buildDefaultBlockMark = ( + spec: IStorylineSpec, + block: IStorylineBlock, + index: number +): IExtensionGroupMarkSpec => { + const hasImage = !!block.image; + const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 14); + const titleLineHeight = Number((spec.title?.style as any)?.lineHeight ?? Math.round(titleFontSize * 1.35)); + + return { + type: 'group' as any, + id: `storyline-block-${block.id ?? index}`, + name: `storyline-block-${index}`, + zIndex: LayoutZIndex.Mark + 1, + style: { + x: (_datum: unknown, ctx: LayoutContext) => getLayout(spec, ctx).blocks[index]?.x ?? 0, + y: (_datum: unknown, ctx: LayoutContext) => getLayout(spec, ctx).blocks[index]?.y ?? 0, + width: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).block.width, + height: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).block.height + }, + children: [ + { + type: 'rect', + name: `storyline-block-bg-${index}`, + interactive: false, + style: { + x: 0, + y: 0, + width: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).block.width, + height: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).block.height, + cornerRadius: 8, + fill: '#ffffff', + stroke: '#d7dce5', + lineWidth: 1, + shadowBlur: 6, + shadowColor: 'rgba(0, 0, 0, 0.08)', + ...spec.block?.style + } + }, + hasImage + ? ({ + type: 'image', + name: `storyline-block-image-${index}`, + interactive: false, + ...omitImageLayoutSpec(spec.image), + style: { + x: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).imageBox.x, + y: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).imageBox.y, + width: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).imageBox.width, + height: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).imageBox.height, + image: block.image, + cornerRadius: 6, + ...spec.image?.style + } + } as ICustomMarkSpec<'image'>) + : null, + block.title + ? ({ + type: 'text', + name: `storyline-block-title-${index}`, + interactive: false, + ...spec.title, + style: { + x: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).textBox.x, + y: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).textBox.y, + text: block.title, + maxLineWidth: (_datum: unknown, ctx: LayoutContext) => + getDefaultBlockMetrics(spec, ctx, index).textBox.width, + fontSize: titleFontSize, + lineHeight: titleLineHeight, + fontWeight: 'bold', + fill: '#1f2430', + textAlign: 'left', + textBaseline: 'top', + ...spec.title?.style + } + } as ICustomMarkSpec<'text'>) + : null, + contentText.length + ? ({ + type: 'text', + name: `storyline-block-content-${index}`, + interactive: false, + ...spec.content, + textType: 'rich', + style: { + x: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).textBox.x, + y: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).contentBox.y, + width: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).textBox.width, + text: buildRichContent(contentText, spec), + maxLineWidth: (_datum: unknown, ctx: LayoutContext) => + getDefaultBlockMetrics(spec, ctx, index).textBox.width, + fontSize: 12, + lineHeight: 18, + heightLimit: (_datum: unknown, ctx: LayoutContext) => + getDefaultBlockMetrics(spec, ctx, index).contentBox.height, + textAlign: 'left', + textBaseline: 'top', + wordBreak: 'break-word', + ellipsis: '...', + fill: '#596173', + ...spec.content?.style + } + } as ICustomMarkSpec<'text'>) + : null + ].filter(Boolean) as ICustomMarkSpec[] + }; +}; + +const buildLinkPath = ( + points: StorylinePoint[], + type: StorylineLineType, + showArrow: boolean, + arrowSize: number +): string => { + const start = points[0]; + const end = points[points.length - 1]; + if (!start || !end) { + return ''; + } + let path: string; + if (type === 'curve') { + const dx = end.x - start.x; + const dy = end.y - start.y; + const curve = Math.max(Math.min(Math.sqrt(dx * dx + dy * dy) * 0.22, 80), 24); + path = + `M ${start.x} ${start.y} ` + + `C ${start.x + dx / 2} ${start.y - curve} ${end.x - dx / 2} ${end.y + curve} ${end.x} ${end.y}`; + } else if (type === 'polyline') { + const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }; + path = `M ${start.x} ${start.y} L ${mid.x} ${start.y} L ${mid.x} ${end.y} L ${end.x} ${end.y}`; + } else { + path = `M ${start.x} ${start.y} L ${end.x} ${end.y}`; + } + + if (!showArrow) { + return path; + } + return `${path} ${buildArrowPath(start, end, arrowSize)}`; +}; + +const buildArrowPath = (start: StorylinePoint, end: StorylinePoint, size: number) => { + const angle = Math.atan2(end.y - start.y, end.x - start.x); + const left = { + x: end.x - Math.cos(angle - Math.PI / 6) * size, + y: end.y - Math.sin(angle - Math.PI / 6) * size + }; + const right = { + x: end.x - Math.cos(angle + Math.PI / 6) * size, + y: end.y - Math.sin(angle + Math.PI / 6) * size + }; + return `M ${left.x} ${left.y} L ${end.x} ${end.y} L ${right.x} ${right.y}`; +}; diff --git a/packages/vchart-extension/src/charts/storyline/layouts/dome.ts b/packages/vchart-extension/src/charts/storyline/layouts/dome.ts new file mode 100644 index 0000000000..cfc242bec9 --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/layouts/dome.ts @@ -0,0 +1,425 @@ +import type { IExtensionGroupMarkSpec } from '@visactor/vchart'; +import { LayoutZIndex } from '@visactor/vchart'; +import type { IStorylineBlock, IStorylineSpec } from '../interface'; +import { + type ICustomMarkSpec, + type LayoutContext, + type StorylinePoint, + buildRichContent, + getRegionGeometry, + getThemeColor, + normalizeLayout, + normalizePadding, + omitImageLayoutSpec, + resolveBlockWidth, + withAlpha +} from './common'; + +// dome 布局:弧形排列 + 底部 centerImage +// image 默认为圆形,DOME_BLOCK_IMAGE_SIZE 即圆的直径 +const DOME_BLOCK_IMAGE_SIZE = 140; +const DOME_TEXT_GAP_FROM_IMAGE = 10; +const DOME_TITLE_LINE_HEIGHT = 19; +const DOME_CONTENT_LINE_HEIGHT = 17; +const DOME_CONTENT_FONT_SIZE = 12; +// title + content 区域总高度(默认 400px,溢出由富文本 heightLimit + ellipsis 自动截断) +const DOME_TEXT_BOX_HEIGHT = 300; +const DOME_TITLE_TO_CONTENT_GAP = 4; +// 引导线与 title/content 之间的水平间距 +const DOME_TEXT_LEFT_PADDING = 20; +const DOME_CENTER_IMAGE_WIDTH_RATIO = 0.32; +const DOME_CENTER_IMAGE_HEIGHT_RATIO = 0.32; +// 弧线最高点(视觉上的顶点)距离 centerImage 顶部的距离 +const DOME_ARC_TOP_GAP_FROM_CENTER_IMAGE = 300; + +/** + * 计算 dome 布局 centerImage 的 box:水平居中、垂直贴底(位于 inner 区域底部)。 + */ +const getDomeCenterImageRect = (spec: IStorylineSpec, ctx: LayoutContext) => { + const { width, height, startX, startY } = getRegionGeometry(ctx); + const padding = normalizePadding(spec.block?.padding); + const innerWidth = Math.max(width - padding.left - padding.right, 1); + const innerHeight = Math.max(height - padding.top - padding.bottom, 1); + const w = Math.max(spec.centerImage?.width ?? innerWidth * DOME_CENTER_IMAGE_WIDTH_RATIO, 80); + const h = Math.max(spec.centerImage?.height ?? innerHeight * DOME_CENTER_IMAGE_HEIGHT_RATIO, 60); + const cx = startX + padding.left + innerWidth / 2; + // 紧贴底部,仅保留 spec.block.padding.bottom 的留白 + const top = startY + padding.top + innerHeight - h; + return { x: cx - w / 2, y: top, width: w, height: h }; +}; + +/** + * 计算 dome 弧线的几何参数: + * - cx / rx / startAngle / endAngle 与 layout.ts 中 arcCenters 一致; + * - cy 与 ry 由两条对齐约束反推: + * 1) 弧线起/终点 y == centerImage 底部 + * 2) 弧线最高点 y == centerImage 顶部 - DOME_ARC_TOP_GAP_FROM_CENTER_IMAGE + * + * 解方程: + * cy + ry * sin(startAngle) = centerImageBottom + * cy - ry = centerImageTop - GAP + * → ry = (centerImageHeight + GAP) / (1 + sin(startAngle)) + * cy = centerImageBottom - ry * sin(startAngle) + * + * 仅当 sin(startAngle) ∈ [-1, 0) 时(即 startAngle 在 (180°, 360°) 区间,碗形), + * 该方程组有合理正解。 + */ +const getDomeArcGeometry = (spec: IStorylineSpec, ctx: LayoutContext) => { + const { width, startX } = getRegionGeometry(ctx); + const blockPadding = normalizePadding(spec.block?.padding); + const innerWidth = Math.max(width - blockPadding.left - blockPadding.right, 1); + const blockWidth = resolveBlockWidth(spec, width); + const layoutOpt = normalizeLayout(spec.layout); + const startAngle = layoutOpt.startAngle ?? 200; + const endAngle = layoutOpt.endAngle ?? 340; + const ratio = layoutOpt.radiusRatio ?? 0.88; + const rx = Math.max((innerWidth - blockWidth) / 2, 1) * ratio; + const centerRect = getDomeCenterImageRect(spec, ctx); + const centerTop = centerRect.y; + const centerBottom = centerRect.y + centerRect.height; + const sinStart = Math.sin((startAngle / 180) * Math.PI); + // sinStart 接近 -1 时 ry → ∞;这里限制下界以防 startAngle 配置异常 + const denom = Math.max(1 + sinStart, 0.05); + const ry = (centerRect.height + DOME_ARC_TOP_GAP_FROM_CENTER_IMAGE) / denom; + const cy = centerBottom - ry * sinStart; + return { + cx: startX + blockPadding.left + innerWidth / 2, + cy, + rx, + ry, + startAngle, + endAngle, + // 调试/对齐用:上下两个对齐参考点 + centerTop, + centerBottom + }; +}; + +/** + * 在 do me 新弧线上按 index 采样 block 中心,与 arc 完全同步。 + * 同时让 block 沿弧线径向向外偏移 imageHeight/2, + * 使 image 内边贴在弧线上,image + text 整体位于弧线外侧。 + */ +const getDomeBlockCenter = (spec: IStorylineSpec, ctx: LayoutContext, index: number): StorylinePoint => { + const arc = getDomeArcGeometry(spec, ctx); + const count = spec.data?.length ?? 0; + if (count <= 0) { + return { x: arc.cx, y: arc.cy }; + } + const t = count === 1 ? 0.5 : index / (count - 1); + const angle = ((arc.startAngle + (arc.endAngle - arc.startAngle) * t) / 180) * Math.PI; + const px = arc.cx + Math.cos(angle) * arc.rx; + const py = arc.cy + Math.sin(angle) * arc.ry; + // 椭圆在 (px,py) 处的外法向量 ∝ (cos(angle)/rx, sin(angle)/ry) + const nxRaw = Math.cos(angle) / arc.rx; + const nyRaw = Math.sin(angle) / arc.ry; + const nLen = Math.hypot(nxRaw, nyRaw) || 1; + const nx = nxRaw / nLen; + const ny = nyRaw / nLen; + const imageHeight = spec.image?.height ?? DOME_BLOCK_IMAGE_SIZE; + const offset = imageHeight / 2; + return { x: px + nx * offset, y: py + ny * offset }; +}; + +/** + * 贯穿所有 block 的半圆弧线 mark(path 通过沿椭圆采样实现, + * 与 dome block 的弧形布局完全重合) + * + * 默认不展示,仅当用户在 spec.line.visible 显式置为 true 时才渲染。 + */ +export const buildDomeArcMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + if (spec.line?.visible !== true) { + return null; + } + const themeColor = getThemeColor(spec); + return { + type: 'group' as any, + name: 'storyline-dome-arc', + zIndex: LayoutZIndex.Mark, + children: [ + { + type: 'path', + name: 'storyline-dome-arc-path', + interactive: false, + style: { + stroke: themeColor, + lineWidth: 2, + lineCap: 'round', + fill: 'transparent', + fillOpacity: 0, + path: (_d: unknown, ctx: LayoutContext) => { + const arc = getDomeArcGeometry(spec, ctx); + const span = arc.endAngle - arc.startAngle; + const samples = 64; + const segments: string[] = []; + for (let i = 0; i <= samples; i++) { + const t = i / samples; + const angle = ((arc.startAngle + span * t) / 180) * Math.PI; + const x = arc.cx + Math.cos(angle) * arc.rx; + const y = arc.cy + Math.sin(angle) * arc.ry; + segments.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`); + } + return segments.join(' '); + } + } + } as ICustomMarkSpec<'path'> + ] + }; +}; + +export const buildDomeCenterImageMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + const visible = spec.centerImage?.visible !== false; + if (!visible) { + return null; + } + const themeColor = getThemeColor(spec); + const hasImage = !!spec.centerImage?.image; + // 主题色生成的线性渐变(顶部偏淡 → 底部主题色),作为 centerImage 位置的 symbol 填充 + const symbolGradient = { + gradient: 'linear', + x0: 0.5, + y0: 0, + x1: 0.5, + y1: 1, + stops: [ + { offset: 0, color: withAlpha(themeColor, 0.15) }, + { offset: 1, color: themeColor } + ] + }; + return { + type: 'group' as any, + name: 'storyline-dome-center', + zIndex: LayoutZIndex.Mark, + children: [ + // 默认 symbol:位于 centerImage 的位置,外径略大于 centerImage(填充渐变作为视觉底盘) + { + type: 'symbol', + name: 'storyline-dome-center-symbol', + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => { + const r = getDomeCenterImageRect(spec, ctx); + return r.x + r.width / 2; + }, + y: (_d: unknown, ctx: LayoutContext) => { + const r = getDomeCenterImageRect(spec, ctx); + return r.y + r.height / 2; + }, + size: (_d: unknown, ctx: LayoutContext) => { + const r = getDomeCenterImageRect(spec, ctx); + // symbol 直径略大于 centerImage 较短边,形成"圆形底盘" + return Math.max(r.width, r.height) * 1.1; + }, + symbolType: 'circle', + fill: symbolGradient, + stroke: themeColor, + lineWidth: 2 + } + } as ICustomMarkSpec<'symbol'>, + { + type: 'rect', + name: 'storyline-dome-center-rect', + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => getDomeCenterImageRect(spec, ctx).x, + y: (_d: unknown, ctx: LayoutContext) => getDomeCenterImageRect(spec, ctx).y, + width: (_d: unknown, ctx: LayoutContext) => getDomeCenterImageRect(spec, ctx).width, + height: (_d: unknown, ctx: LayoutContext) => getDomeCenterImageRect(spec, ctx).height, + cornerRadius: 12, + fill: '#ffffff', + stroke: themeColor, + lineWidth: 2 + } + } as ICustomMarkSpec<'rect'>, + hasImage + ? ({ + type: 'image', + name: 'storyline-dome-center-image', + interactive: false, + ...spec.centerImage, + style: { + x: (_d: unknown, ctx: LayoutContext) => getDomeCenterImageRect(spec, ctx).x, + y: (_d: unknown, ctx: LayoutContext) => getDomeCenterImageRect(spec, ctx).y, + width: (_d: unknown, ctx: LayoutContext) => getDomeCenterImageRect(spec, ctx).width, + height: (_d: unknown, ctx: LayoutContext) => getDomeCenterImageRect(spec, ctx).height, + image: spec.centerImage?.image, + cornerRadius: 12, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'cover', + imagePosition: 'center', + ...spec.centerImage?.style + } + } as ICustomMarkSpec<'image'>) + : null + ].filter(Boolean) as ICustomMarkSpec[] + }; +}; + +const getDomeBlockMetrics = (spec: IStorylineSpec) => { + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 14); + const titleLineHeight = Number( + (spec.title?.style as any)?.lineHeight ?? Math.max(DOME_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.35)) + ); + const contentFontSize = Number((spec.content?.style as any)?.fontSize ?? DOME_CONTENT_FONT_SIZE); + const contentLineHeight = Number((spec.content?.style as any)?.lineHeight ?? DOME_CONTENT_LINE_HEIGHT); + const titleToContentGap = DOME_TITLE_TO_CONTENT_GAP; + // text 区域总高度固定为 DOME_TEXT_BOX_HEIGHT,content 占除 title 与间距外的全部高度 + const textHeight = DOME_TEXT_BOX_HEIGHT; + const contentHeight = Math.max(textHeight - titleLineHeight - titleToContentGap, contentLineHeight); + + const imageWidth = spec.image?.width ?? DOME_BLOCK_IMAGE_SIZE; + const imageHeight = spec.image?.height ?? DOME_BLOCK_IMAGE_SIZE; + + // image 位于 block 中心下半部分,title/content 在 image 上方 + const imageBox = { + x: -imageWidth / 2, + y: -imageHeight / 2, + width: imageWidth, + height: imageHeight + }; + const textBox = { + x: -imageWidth / 2 + DOME_TEXT_LEFT_PADDING, + y: imageBox.y - DOME_TEXT_GAP_FROM_IMAGE - textHeight, + width: imageWidth - DOME_TEXT_LEFT_PADDING, + height: textHeight + }; + const contentBox = { + x: textBox.x, + y: textBox.y + titleLineHeight + titleToContentGap, + width: textBox.width, + height: contentHeight + }; + return { + titleFontSize, + titleLineHeight, + contentFontSize, + contentLineHeight, + imageBox, + textBox, + contentBox + }; +}; + +export const buildDomeBlockMark = ( + spec: IStorylineSpec, + block: IStorylineBlock, + index: number +): IExtensionGroupMarkSpec => { + const hasImage = !!block.image; + const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; + const themeColor = getThemeColor(spec); + const metrics = getDomeBlockMetrics(spec); + + return { + type: 'group' as any, + id: `storyline-block-${block.id ?? index}`, + name: `storyline-block-${index}`, + zIndex: LayoutZIndex.Mark + 1, + style: { + x: (_d: unknown, ctx: LayoutContext) => getDomeBlockCenter(spec, ctx, index).x, + y: (_d: unknown, ctx: LayoutContext) => getDomeBlockCenter(spec, ctx, index).y + }, + children: [ + // title / content 左侧的垂直引导线(贯穿 text + image 顶部,与文字保持 padding) + { + type: 'rect', + name: `storyline-block-connector-${index}`, + interactive: false, + style: { + x: metrics.imageBox.x, + y: metrics.textBox.y, + width: 2, + height: Math.max(metrics.imageBox.y - metrics.textBox.y, 0), + fill: themeColor, + fillOpacity: 0.6 + } + } as ICustomMarkSpec<'rect'>, + hasImage + ? ({ + type: 'image', + name: `storyline-block-image-${index}`, + interactive: false, + ...omitImageLayoutSpec(spec.image), + style: { + x: metrics.imageBox.x, + y: metrics.imageBox.y, + width: metrics.imageBox.width, + height: metrics.imageBox.height, + image: block.image, + // 圆形裁剪:cornerRadius = min(w,h) / 2 + cornerRadius: Math.min(metrics.imageBox.width, metrics.imageBox.height) / 2, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'cover', + imagePosition: 'center', + stroke: themeColor, + lineWidth: 2, + ...spec.image?.style + } + } as ICustomMarkSpec<'image'>) + : ({ + type: 'rect', + name: `storyline-block-image-bg-${index}`, + interactive: false, + style: { + x: metrics.imageBox.x, + y: metrics.imageBox.y, + width: metrics.imageBox.width, + height: metrics.imageBox.height, + cornerRadius: Math.min(metrics.imageBox.width, metrics.imageBox.height) / 2, + fill: '#ffffff', + stroke: themeColor, + lineWidth: 2 + } + } as ICustomMarkSpec<'rect'>), + block.title + ? ({ + type: 'text', + name: `storyline-block-title-${index}`, + interactive: false, + ...spec.title, + style: { + x: metrics.textBox.x, + y: metrics.textBox.y, + text: block.title, + maxLineWidth: metrics.textBox.width, + fontSize: metrics.titleFontSize, + lineHeight: metrics.titleLineHeight, + fontWeight: 'bold', + fill: '#1f2430', + textAlign: 'left', + textBaseline: 'top', + ...spec.title?.style + } + } as ICustomMarkSpec<'text'>) + : null, + contentText.length + ? ({ + type: 'text', + name: `storyline-block-content-${index}`, + interactive: false, + ...spec.content, + textType: 'rich', + style: { + x: metrics.contentBox.x, + y: metrics.contentBox.y, + width: metrics.contentBox.width, + height: metrics.contentBox.height, + maxLineWidth: metrics.contentBox.width, + heightLimit: metrics.contentBox.height, + text: buildRichContent(contentText, spec), + fontSize: DOME_CONTENT_FONT_SIZE, + lineHeight: DOME_CONTENT_LINE_HEIGHT, + textAlign: 'left', + textBaseline: 'top', + wordBreak: 'break-word', + ellipsis: '...', + fill: '#596173', + ...spec.content?.style + } + } as ICustomMarkSpec<'text'>) + : null + ].filter(Boolean) as ICustomMarkSpec[] + }; +}; diff --git a/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts b/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts new file mode 100644 index 0000000000..0d59dbb3ba --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts @@ -0,0 +1,349 @@ +import type { IExtensionGroupMarkSpec } from '@visactor/vchart'; +import { LayoutZIndex } from '@visactor/vchart'; +import type { IStorylineBlock, IStorylineSpec } from '../interface'; +import { + type ICustomMarkSpec, + type LayoutContext, + type StorylinePoint, + DEFAULT_BLOCK_HEIGHT, + buildRichContent, + buildSmoothCurvePath, + getLayout, + getThemeColor, + normalizePadding, + omitImageLayoutSpec, + resolveBlockWidth +} from './common'; + +// landscape 布局下,image rect 与 text rect 分离展示 +const LANDSCAPE_IMAGE_HEIGHT_RATIO = 0.42; +const LANDSCAPE_DETACHED_GAP = 64; +const LANDSCAPE_CONNECTOR_ARROW_SIZE = 9; +const LANDSCAPE_CONNECTOR_X_RATIO = 0.2; // 引导线 x 位于 image 左侧 20% 处 +const LANDSCAPE_TEXT_GAP_FROM_CONNECTOR = 12; // 文字距离引导线的水平间距 +// content 区固定为 4 行,整体 textHeight = titleLineHeight + titleGap + contentLines * contentLineHeight +const LANDSCAPE_CONTENT_LINES = 4; +const LANDSCAPE_TITLE_LINE_HEIGHT = 19; +const LANDSCAPE_CONTENT_LINE_HEIGHT = 18; +const LANDSCAPE_CONTENT_FONT_SIZE = 12; +const LANDSCAPE_TITLE_TO_CONTENT_GAP = 4; + +/** + * 计算第 index 个 block 在 landscape 布局下的 image 中心点(含 stagger 错落偏移)。 + */ +const getLandscapeImageCenter = (spec: IStorylineSpec, ctx: LayoutContext, index: number): StorylinePoint | null => { + const lb = getLayout(spec, ctx).blocks[index]; + if (!lb) { + return null; + } + const cx = lb.center?.x ?? lb.x + lb.width / 2; + const cy = lb.center?.y ?? lb.y + lb.height / 2; + // 与 buildLandscapeBlockMark 中 group y 的 stagger 偏移保持一致 + const stagger = (index % 2 === 0 ? -1 : 1) * lb.height * 0.1; + return { x: cx, y: cy + stagger }; +}; + +/** + * landscape 下绘制一条贯穿所有 image 中心的平滑虚线曲线,并在每个节点位置画 symbol, + * 颜色跟随主题色。 + */ +export const buildLandscapeConnectingCurve = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + const themeColor = getThemeColor(spec); + const lineStyle = spec.line?.style ?? {}; + const count = spec.data?.length ?? 0; + const symbolSize = 14; + const symbolChildren: ICustomMarkSpec<'symbol'>[] = []; + for (let i = 0; i < count; i++) { + const idx = i; + symbolChildren.push({ + type: 'symbol', + name: `storyline-landscape-curve-symbol-${idx}`, + interactive: false, + style: { + symbolType: 'circle', + size: symbolSize, + fill: themeColor, + x: (_d: unknown, ctx: LayoutContext) => getLandscapeImageCenter(spec, ctx, idx)?.x ?? 0, + y: (_d: unknown, ctx: LayoutContext) => getLandscapeImageCenter(spec, ctx, idx)?.y ?? 0 + } + } as ICustomMarkSpec<'symbol'>); + } + return { + type: 'group' as any, + name: 'storyline-landscape-curve', + zIndex: LayoutZIndex.Mark + 2, + children: [ + { + type: 'path', + name: 'storyline-landscape-curve-path', + interactive: false, + style: { + stroke: (lineStyle as any).stroke ?? themeColor, + lineWidth: (lineStyle as any).lineWidth ?? 4, + lineDash: (lineStyle as any).lineDash ?? [6, 5], + lineCap: 'round', + fill: 'transparent', + fillOpacity: 0, + path: (_d: unknown, ctx: LayoutContext) => { + const points: StorylinePoint[] = []; + for (let i = 0; i < count; i++) { + const center = getLandscapeImageCenter(spec, ctx, i); + if (center) { + points.push(center); + } + } + return buildSmoothCurvePath(points); + } + } + } as ICustomMarkSpec<'path'>, + ...symbolChildren + ] + }; +}; + +/** + * landscape 布局下,每个 block 拆分为 image rect 与 text rect 两个独立卡片, + * 中间用主题色虚线箭头连接;title+content 在 image 上方/下方交替错落摆放。 + */ +const getLandscapeMetrics = (spec: IStorylineSpec, blockWidth: number, blockHeight: number, index: number) => { + const padding = normalizePadding(spec.block?.padding ?? 12); + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 14); + const titleLineHeight = Number( + (spec.title?.style as any)?.lineHeight ?? Math.max(LANDSCAPE_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.35)) + ); + const contentFontSize = Number((spec.content?.style as any)?.fontSize ?? LANDSCAPE_CONTENT_FONT_SIZE); + const contentLineHeight = Number((spec.content?.style as any)?.lineHeight ?? LANDSCAPE_CONTENT_LINE_HEIGHT); + + const imageHeight = Math.max( + spec.image?.height ?? Math.round(blockHeight * LANDSCAPE_IMAGE_HEIGHT_RATIO), + titleLineHeight + padding.top + padding.bottom + ); + const connectorGap = LANDSCAPE_DETACHED_GAP; + const contentHeight = LANDSCAPE_CONTENT_LINES * contentLineHeight; + const titleToContentGap = LANDSCAPE_TITLE_TO_CONTENT_GAP; + const textHeight = titleLineHeight + titleToContentGap + contentHeight; + + const textOnTop = index % 2 === 0; + + let textBox: { x: number; y: number; width: number; height: number }; + let contentBox: { x: number; y: number; width: number; height: number }; + let imageBox: { x: number; y: number; width: number; height: number }; + let connector: { x1: number; y1: number; x2: number; y2: number }; + let groupTop: number; + let groupHeight: number; + + const imageX = 0; + const connectorX = imageX + blockWidth * LANDSCAPE_CONNECTOR_X_RATIO; + const textX = connectorX + LANDSCAPE_TEXT_GAP_FROM_CONNECTOR; + const textWidth = Math.max(blockWidth - (textX - imageX), 0); + + if (textOnTop) { + const imageY = 0; + const textY = imageY - connectorGap - textHeight; + const connectorY1 = imageY; + const connectorY2 = textY + titleLineHeight / 2; + + imageBox = { x: imageX, y: imageY, width: blockWidth, height: imageHeight }; + textBox = { x: textX, y: textY, width: textWidth, height: textHeight }; + contentBox = { + x: textX, + y: textY + titleLineHeight + titleToContentGap, + width: textWidth, + height: contentHeight + }; + connector = { x1: connectorX, y1: connectorY1, x2: connectorX, y2: connectorY2 }; + groupTop = textY; + groupHeight = imageHeight - groupTop; + } else { + const imageY = 0; + const textY = imageY + imageHeight + connectorGap; + const connectorY1 = imageY + imageHeight; + const connectorY2 = textY + textHeight; + + imageBox = { x: imageX, y: imageY, width: blockWidth, height: imageHeight }; + textBox = { x: textX, y: textY, width: textWidth, height: textHeight }; + contentBox = { + x: textX, + y: textY + titleLineHeight + titleToContentGap, + width: textWidth, + height: contentHeight + }; + connector = { x1: connectorX, y1: connectorY1, x2: connectorX, y2: connectorY2 }; + groupTop = imageY; + groupHeight = textY + textHeight - imageY; + } + + return { + padding, + titleFontSize, + titleLineHeight, + contentFontSize, + contentLineHeight, + contentHeight, + blockWidth, + imageBox, + textBox, + contentBox, + connector, + textOnTop, + groupTop, + groupHeight + }; +}; + +export const buildLandscapeBlockMark = ( + spec: IStorylineSpec, + block: IStorylineBlock, + index: number +): IExtensionGroupMarkSpec => { + const hasImage = !!block.image; + const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 14); + const titleLineHeight = Number((spec.title?.style as any)?.lineHeight ?? Math.round(titleFontSize * 1.35)); + + const getMetrics = (ctx: LayoutContext) => { + const layoutBlock = getLayout(spec, ctx).blocks[index]; + const w = layoutBlock?.width ?? resolveBlockWidth(spec, 0); + const h = layoutBlock?.height ?? spec.block?.height ?? DEFAULT_BLOCK_HEIGHT; + return getLandscapeMetrics(spec, w, h, index); + }; + + const blockStyle = spec.block?.style ?? {}; + const lineStyle = spec.line?.style ?? {}; + const themeColor = getThemeColor(spec); + const connectorStroke = (lineStyle as any).stroke ?? themeColor; + const connectorLineWidth = (lineStyle as any).lineWidth ?? 2; + const connectorDash = (lineStyle as any).lineDash ?? [4, 4]; + + return { + type: 'group' as any, + id: `storyline-block-${block.id ?? index}`, + name: `storyline-block-${index}`, + zIndex: LayoutZIndex.Mark + 1, + style: { + x: (_d: unknown, ctx: LayoutContext) => { + const lb = getLayout(spec, ctx).blocks[index]; + return lb?.x ?? 0; + }, + y: (_d: unknown, ctx: LayoutContext) => { + const lb = getLayout(spec, ctx).blocks[index]; + const m = getMetrics(ctx); + const cy = lb?.center?.y ?? (lb?.y ?? 0) + (lb?.height ?? 0) / 2; + const blockH = lb?.height ?? spec.block?.height ?? DEFAULT_BLOCK_HEIGHT; + const stagger = (index % 2 === 0 ? -1 : 1) * blockH * 0.1; + return cy - m.imageBox.height / 2 + stagger; + }, + width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).blockWidth, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).groupHeight + }, + children: [ + { + type: 'rect', + name: `storyline-block-image-bg-${index}`, + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.y, + width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.width, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.height, + cornerRadius: 8, + fill: '#ffffff', + stroke: themeColor, + lineWidth: 2, + ...blockStyle + } + } as ICustomMarkSpec<'rect'>, + hasImage + ? ({ + type: 'image', + name: `storyline-block-image-${index}`, + interactive: false, + ...omitImageLayoutSpec(spec.image), + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.y, + width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.width, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.height, + image: block.image, + cornerRadius: 8, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'cover', + imagePosition: 'center', + ...spec.image?.style + } + } as ICustomMarkSpec<'image'>) + : null, + { + type: 'path', + name: `storyline-block-connector-${index}`, + interactive: false, + style: { + stroke: connectorStroke, + lineWidth: connectorLineWidth, + lineDash: connectorDash, + fill: connectorStroke, + path: (_d: unknown, ctx: LayoutContext) => { + const m = getMetrics(ctx); + const tipSize = LANDSCAPE_CONNECTOR_ARROW_SIZE; + const x = m.connector.x1; + const y0 = m.connector.y1; + const y1 = m.connector.y2; + const tipDir = y1 < y0 ? -1 : 1; + const baseY = y1 - tipDir * tipSize; + const dashLine = `M ${x} ${y0} L ${x} ${baseY}`; + const triangle = `M ${x - tipSize / 2} ${baseY} L ${x + tipSize / 2} ${baseY} L ${x} ${y1} Z`; + return `${dashLine} ${triangle}`; + } + } + } as ICustomMarkSpec<'path'>, + block.title + ? ({ + type: 'text', + name: `storyline-block-title-${index}`, + interactive: false, + ...spec.title, + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textBox.y, + text: block.title, + maxLineWidth: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textBox.width, + fontSize: titleFontSize, + lineHeight: titleLineHeight, + fontWeight: 'bold', + fill: '#1f2430', + textAlign: 'left', + textBaseline: 'top', + ...spec.title?.style + } + } as ICustomMarkSpec<'text'>) + : null, + contentText.length + ? ({ + type: 'text', + name: `storyline-block-content-${index}`, + interactive: false, + ...spec.content, + textType: 'rich', + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.y, + width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.width, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.height, + maxLineWidth: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.width, + heightLimit: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.height, + text: buildRichContent(contentText, spec), + fontSize: LANDSCAPE_CONTENT_FONT_SIZE, + lineHeight: LANDSCAPE_CONTENT_LINE_HEIGHT, + textAlign: 'left', + textBaseline: 'top', + wordBreak: 'break-word', + ellipsis: '...', + fill: '#596173', + ...spec.content?.style + } + } as ICustomMarkSpec<'text'>) + : null + ].filter(Boolean) as ICustomMarkSpec[] + }; +}; diff --git a/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts b/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts new file mode 100644 index 0000000000..8e2474d3b7 --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts @@ -0,0 +1,320 @@ +import type { IExtensionGroupMarkSpec } from '@visactor/vchart'; +import { LayoutZIndex } from '@visactor/vchart'; +import type { IStorylineBlock, IStorylineSpec } from '../interface'; +import { + type ICustomMarkSpec, + type LayoutContext, + DEFAULT_BLOCK_HEIGHT, + buildRichContent, + getLayout, + getThemeColor, + omitImageLayoutSpec, + resolveBlockWidth, + withAlpha +} from './common'; + +// portrait 布局:中轴 rect + 左右交替的 image + image 下方 title/content +const PORTRAIT_AXIS_WIDTH = 64; +const PORTRAIT_AXIS_PADDING = 50; // 中轴上下两端的留白 +const PORTRAIT_IMAGE_WIDTH = 180; +const PORTRAIT_IMAGE_HEIGHT = 110; +const PORTRAIT_IMAGE_GAP_FROM_AXIS = 24; // image 与中轴之间的水平间距 +const PORTRAIT_SHADOW_OFFSET_X = 36; +const PORTRAIT_SHADOW_OFFSET_Y = 20; +const PORTRAIT_SHADOW_SCALE = 1.12; +const PORTRAIT_TEXT_GAP_FROM_IMAGE = 8; +const PORTRAIT_CONTENT_LINES = 3; +const PORTRAIT_TITLE_LINE_HEIGHT = 19; +const PORTRAIT_CONTENT_LINE_HEIGHT = 18; +const PORTRAIT_CONTENT_FONT_SIZE = 12; +const PORTRAIT_TITLE_TO_CONTENT_GAP = 4; + +/** + * 获取 portrait 布局的中轴 rect 尺寸:宽度固定,高度贯穿首/尾 block 中心。 + */ +const getPortraitAxisRect = (spec: IStorylineSpec, ctx: LayoutContext) => { + const blocks = getLayout(spec, ctx).blocks; + if (!blocks.length) { + return { x: 0, y: 0, width: 0, height: 0 }; + } + const firstCy = blocks[0].center.y; + const lastCy = blocks[blocks.length - 1].center.y; + const top = Math.min(firstCy, lastCy); + const bottom = Math.max(firstCy, lastCy); + const cx = blocks[0].center.x; + return { + x: cx - PORTRAIT_AXIS_WIDTH / 2, + y: top - PORTRAIT_AXIS_PADDING, + width: PORTRAIT_AXIS_WIDTH, + height: bottom - top + PORTRAIT_AXIS_PADDING * 2 + }; +}; + +export const buildPortraitAxisMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec => { + const themeColor = getThemeColor(spec); + const lineStyle = spec.line?.style ?? {}; + const defaultFill = { + gradient: 'linear', + x0: 0, + y0: 0, + x1: 0, + y1: 1, + stops: [ + { offset: 0, color: withAlpha(themeColor, 0.2) }, + { offset: 1, color: withAlpha(themeColor, 1) } + ] + }; + return { + type: 'group' as any, + name: 'storyline-portrait-axis', + zIndex: LayoutZIndex.Mark, + children: [ + { + type: 'rect', + name: 'storyline-portrait-axis-rect', + interactive: false, + style: { + fill: (lineStyle as any).fill ?? defaultFill, + stroke: (lineStyle as any).stroke ?? false, + lineWidth: (lineStyle as any).lineWidth ?? 0, + cornerRadius: (lineStyle as any).cornerRadius ?? 0, + x: (_d: unknown, ctx: LayoutContext) => getPortraitAxisRect(spec, ctx).x, + y: (_d: unknown, ctx: LayoutContext) => getPortraitAxisRect(spec, ctx).y, + width: (_d: unknown, ctx: LayoutContext) => getPortraitAxisRect(spec, ctx).width, + height: (_d: unknown, ctx: LayoutContext) => getPortraitAxisRect(spec, ctx).height + } + } as ICustomMarkSpec<'rect'> + ] + }; +}; + +const getPortraitMetrics = (spec: IStorylineSpec, blockWidth: number, _blockHeight: number, index: number) => { + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 14); + const titleLineHeight = Number( + (spec.title?.style as any)?.lineHeight ?? Math.max(PORTRAIT_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.35)) + ); + const contentFontSize = Number((spec.content?.style as any)?.fontSize ?? PORTRAIT_CONTENT_FONT_SIZE); + const contentLineHeight = Number((spec.content?.style as any)?.lineHeight ?? PORTRAIT_CONTENT_LINE_HEIGHT); + const contentHeight = PORTRAIT_CONTENT_LINES * contentLineHeight; + const titleToContentGap = PORTRAIT_TITLE_TO_CONTENT_GAP; + const textHeight = titleLineHeight + titleToContentGap + contentHeight; + + const imageWidth = spec.image?.width ?? PORTRAIT_IMAGE_WIDTH; + const imageHeight = spec.image?.height ?? PORTRAIT_IMAGE_HEIGHT; + + const onLeft = index % 2 === 0; + + const axisHalf = PORTRAIT_AXIS_WIDTH / 2; + const imageX = onLeft + ? -axisHalf - PORTRAIT_IMAGE_GAP_FROM_AXIS - imageWidth + : axisHalf + PORTRAIT_IMAGE_GAP_FROM_AXIS; + const imageY = -imageHeight / 2; + + const textX = imageX; + const textY = imageY + imageHeight + PORTRAIT_TEXT_GAP_FROM_IMAGE; + const textWidth = imageWidth; + + const contentBox = { + x: textX, + y: textY + titleLineHeight + titleToContentGap, + width: textWidth, + height: contentHeight + }; + + const shadowOffsetX = PORTRAIT_SHADOW_OFFSET_X; + const shadowOffsetY = PORTRAIT_SHADOW_OFFSET_Y; + const shadowWidth = imageWidth * PORTRAIT_SHADOW_SCALE; + const shadowHeight = imageHeight * PORTRAIT_SHADOW_SCALE; + const baseShadowX = imageX - (shadowWidth - imageWidth) / 2; + const baseShadowY = imageY - (shadowHeight - imageHeight) / 2; + const shadowBox = { + x: baseShadowX + (onLeft ? -shadowOffsetX : shadowOffsetX), + y: baseShadowY + shadowOffsetY, + width: shadowWidth, + height: shadowHeight + }; + + return { + onLeft, + titleFontSize, + titleLineHeight, + contentFontSize, + contentLineHeight, + blockWidth, + imageBox: { x: imageX, y: imageY, width: imageWidth, height: imageHeight }, + shadowBox, + textBox: { x: textX, y: textY, width: textWidth, height: textHeight }, + contentBox + }; +}; + +export const buildPortraitBlockMark = ( + spec: IStorylineSpec, + block: IStorylineBlock, + index: number +): IExtensionGroupMarkSpec => { + const hasImage = !!block.image; + const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 14); + const titleLineHeight = Number( + (spec.title?.style as any)?.lineHeight ?? Math.max(PORTRAIT_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.35)) + ); + + const getMetrics = (ctx: LayoutContext) => { + const lb = getLayout(spec, ctx).blocks[index]; + const w = lb?.width ?? resolveBlockWidth(spec, 0); + const h = lb?.height ?? spec.block?.height ?? DEFAULT_BLOCK_HEIGHT; + return getPortraitMetrics(spec, w, h, index); + }; + const themeColor = getThemeColor(spec); + const blockStyle = spec.block?.style ?? {}; + + return { + type: 'group' as any, + id: `storyline-block-${block.id ?? index}`, + name: `storyline-block-${index}`, + zIndex: LayoutZIndex.Mark + 1, + style: { + x: (_d: unknown, ctx: LayoutContext) => { + const lb = getLayout(spec, ctx).blocks[index]; + return lb?.center?.x ?? 0; + }, + y: (_d: unknown, ctx: LayoutContext) => { + const lb = getLayout(spec, ctx).blocks[index]; + return lb?.center?.y ?? 0; + } + }, + children: [ + hasImage + ? ({ + type: 'image', + name: `storyline-block-shadow-image-${index}`, + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).shadowBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).shadowBox.y, + width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).shadowBox.width, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).shadowBox.height, + image: block.image, + cornerRadius: 8, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'cover', + imagePosition: 'center' + } + } as ICustomMarkSpec<'image'>) + : null, + hasImage + ? ({ + type: 'rect', + name: `storyline-block-shadow-mask-${index}`, + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).shadowBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).shadowBox.y, + width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).shadowBox.width, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).shadowBox.height, + cornerRadius: 8, + stroke: false, + lineWidth: 0, + fill: { + gradient: 'linear', + x0: 0, + y0: 0, + x1: 0, + y1: 1, + stops: [ + { offset: 0, color: withAlpha(themeColor, 0.2) }, + { offset: 1, color: withAlpha(themeColor, 1) } + ] + } + } + } as ICustomMarkSpec<'rect'>) + : null, + { + type: 'rect', + name: `storyline-block-image-bg-${index}`, + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.y, + width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.width, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.height, + cornerRadius: 8, + fill: '#ffffff', + stroke: themeColor, + lineWidth: 2, + ...blockStyle + } + } as ICustomMarkSpec<'rect'>, + hasImage + ? ({ + type: 'image', + name: `storyline-block-image-${index}`, + interactive: false, + ...omitImageLayoutSpec(spec.image), + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.y, + width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.width, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.height, + image: block.image, + cornerRadius: 8, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'cover', + imagePosition: 'center', + ...spec.image?.style + } + } as ICustomMarkSpec<'image'>) + : null, + block.title + ? ({ + type: 'text', + name: `storyline-block-title-${index}`, + interactive: false, + ...spec.title, + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textBox.y, + text: block.title, + maxLineWidth: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textBox.width, + fontSize: titleFontSize, + lineHeight: titleLineHeight, + fontWeight: 'bold', + fill: '#1f2430', + textAlign: 'left', + textBaseline: 'top', + ...spec.title?.style + } + } as ICustomMarkSpec<'text'>) + : null, + contentText.length + ? ({ + type: 'text', + name: `storyline-block-content-${index}`, + interactive: false, + ...spec.content, + textType: 'rich', + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.y, + width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.width, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.height, + maxLineWidth: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.width, + heightLimit: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.height, + text: buildRichContent(contentText, spec), + fontSize: PORTRAIT_CONTENT_FONT_SIZE, + lineHeight: PORTRAIT_CONTENT_LINE_HEIGHT, + textAlign: 'left', + textBaseline: 'top', + wordBreak: 'break-word', + ellipsis: '...', + fill: '#596173', + ...spec.content?.style + } + } as ICustomMarkSpec<'text'>) + : null + ].filter(Boolean) as ICustomMarkSpec[] + }; +}; diff --git a/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts b/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts new file mode 100644 index 0000000000..cde2949422 --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts @@ -0,0 +1,126 @@ +import { CommonChartSpecTransformer, type IExtensionGroupMarkSpec } from '@visactor/vchart'; +import type { IStorylineBlock, IStorylineSpec } from './interface'; +import { isBowl, isClock, isDome, isLandscape, isPortrait } from './layouts/common'; +import { buildClockArcMark, buildClockBlockMark, buildClockCenterImageMark } from './layouts/clock'; +import { buildDefaultBlockMark, buildDefaultLineMark } from './layouts/default'; +import { buildLandscapeBlockMark, buildLandscapeConnectingCurve } from './layouts/landscape'; +import { buildPortraitAxisMark, buildPortraitBlockMark } from './layouts/portrait'; +import { buildDomeArcMark, buildDomeBlockMark, buildDomeCenterImageMark } from './layouts/dome'; +import { buildBowlArcMark, buildBowlBlockMark, buildBowlCenterImageMark } from './layouts/bowl'; + +export class StorylineChartSpecTransformer extends CommonChartSpecTransformer { + transformSpec(spec: any): void { + applyDefaultPadding(spec); + const storylineSpec = { + ...spec, + data: [...(spec.data ?? [])] + } as IStorylineSpec; + + spec.type = 'common' as any; + spec.data = []; + spec.series = []; + spec.axes = []; + spec.customMark = buildStorylineMarks(storylineSpec); + delete spec.layout; + delete spec.title; + super.transformSpec(spec as any); + } +} + +/** + * 图表默认 padding:所有 storyline 布局底部默认留 100px, + * 给 dome 的 centerImage / 其它布局的引导线留出呼吸空间。 + * 用户在 spec.padding 中显式指定的值会被保留,仅在缺省时生效。 + */ +const applyDefaultPadding = (spec: any) => { + const DEFAULT_BOTTOM = 100; + const DEFAULT_OTHER = 20; + const p = spec.padding; + if (p === undefined || p === null) { + spec.padding = [DEFAULT_OTHER, DEFAULT_OTHER, DEFAULT_BOTTOM, DEFAULT_OTHER]; + return; + } + if (typeof p === 'number') { + spec.padding = [p, p, Math.max(p, DEFAULT_BOTTOM), p]; + return; + } + if (Array.isArray(p)) { + const [t = DEFAULT_OTHER, r = DEFAULT_OTHER, b, l = DEFAULT_OTHER] = p; + spec.padding = [t, r, b ?? DEFAULT_BOTTOM, l]; + return; + } + if (typeof p === 'object') { + spec.padding = { + top: p.top ?? DEFAULT_OTHER, + right: p.right ?? DEFAULT_OTHER, + bottom: p.bottom ?? DEFAULT_BOTTOM, + left: p.left ?? DEFAULT_OTHER + }; + } +}; + +const buildStorylineMarks = (spec: IStorylineSpec) => { + const lineMark = buildLineMark(spec); + const blockMarks = (spec.data ?? []).map((block, index) => buildBlockMark(spec, block, index)); + // landscape:连接曲线绘制在所有 block 之上,避免被 image 遮挡 + if (isLandscape(spec)) { + return [...blockMarks, lineMark].filter(Boolean) as IExtensionGroupMarkSpec[]; + } + // portrait:lineMark 是中轴 rect,作为底层背景先绘制 + if (isPortrait(spec)) { + return [lineMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; + } + // dome:先绘制 centerImage(最底层视觉锚点),再绘制贯穿 block 的弧线,最后绘制 block; + // dome 不绘制 block 之间默认的连接线 + if (isDome(spec)) { + const centerImageMark = buildDomeCenterImageMark(spec); + const arcMark = buildDomeArcMark(spec); + return [centerImageMark, arcMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; + } + // bowl:dome 的上下镜像 —— centerImage 贴顶,弧线 + block 在下方 + if (isBowl(spec)) { + const centerImageMark = buildBowlCenterImageMark(spec); + const arcMark = buildBowlArcMark(spec); + return [centerImageMark, arcMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; + } + // clock:辐射式信息盘 —— 圆环骨架 + 径向分隔线 → centerImage(盘心)→ blocks(楔形 + 外圈文字) + if (isClock(spec)) { + const ringsMark = buildClockArcMark(spec); + const centerImageMark = buildClockCenterImageMark(spec); + return [ringsMark, ...blockMarks, centerImageMark].filter(Boolean) as IExtensionGroupMarkSpec[]; + } + return [lineMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; +}; + +const buildLineMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + if (spec.line?.visible === false || (spec.data?.length ?? 0) <= 1) { + return null; + } + if (isLandscape(spec)) { + return buildLandscapeConnectingCurve(spec); + } + if (isPortrait(spec)) { + return buildPortraitAxisMark(spec); + } + return buildDefaultLineMark(spec); +}; + +const buildBlockMark = (spec: IStorylineSpec, block: IStorylineBlock, index: number): IExtensionGroupMarkSpec => { + if (isLandscape(spec)) { + return buildLandscapeBlockMark(spec, block, index); + } + if (isPortrait(spec)) { + return buildPortraitBlockMark(spec, block, index); + } + if (isDome(spec)) { + return buildDomeBlockMark(spec, block, index); + } + if (isBowl(spec)) { + return buildBowlBlockMark(spec, block, index); + } + if (isClock(spec)) { + return buildClockBlockMark(spec, block, index); + } + + return buildDefaultBlockMark(spec, block, index); +}; diff --git a/packages/vchart-extension/src/charts/storyline/storyline.ts b/packages/vchart-extension/src/charts/storyline/storyline.ts new file mode 100644 index 0000000000..8920d55256 --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/storyline.ts @@ -0,0 +1,61 @@ +import { + BaseChart, + VChart, + registerArcMark, + registerCommonChart, + registerCustomMark, + registerGroupMark, + registerImageMark, + registerLineMark, + registerPathMark, + registerRectMark, + registerTextMark +} from '@visactor/vchart'; +import type { IStorylineSpec } from './interface'; +import { StorylineChartSpecTransformer } from './storyline-transformer'; + +export class StorylineChart extends BaseChart< + Omit +> { + type = 'storyline'; + static type = 'storyline'; + static readonly view: string = 'singleDefault'; + + declare _spec: T; + + static readonly transformerConstructor = StorylineChartSpecTransformer; + readonly transformerConstructor = StorylineChartSpecTransformer; + + init() { + if (!this.isValid()) { + return; + } + super.init(); + } + + protected isValid() { + const { data } = this._spec; + if (!Array.isArray(data)) { + this._option.onError?.('Data is required and should be an array for storyline chart'); + return false; + } + return true; + } +} + +export const registerStorylineChart = (option?: { VChart?: typeof VChart }) => { + registerCommonChart(); + registerCustomMark(); + registerGroupMark(); + registerRectMark(); + registerTextMark(); + registerImageMark(); + registerLineMark(); + registerPathMark(); + registerArcMark(); + + const vchartConstructor = option?.VChart || VChart; + if (vchartConstructor) { + vchartConstructor.useChart([StorylineChart]); + } +}; diff --git a/packages/vchart-extension/src/index.ts b/packages/vchart-extension/src/index.ts index bc546ab45a..d8f80b5d97 100644 --- a/packages/vchart-extension/src/index.ts +++ b/packages/vchart-extension/src/index.ts @@ -18,6 +18,7 @@ export * from './charts/pictogram'; export * from './charts/image-cloud'; export * from './charts/candlestick'; export * from './charts/timeline'; +export * from './charts/storyline'; export * from './components/series-break'; export * from './components/bar-link'; From 0bbcdb2543248b6bcd3b48746cffd12f5ff92453 Mon Sep 17 00:00:00 2001 From: skie1997 Date: Thu, 11 Jun 2026 18:01:20 +0800 Subject: [PATCH 2/8] feat: enhance layout of storyline chart --- .../runtime/browser/test-page/storyline.ts | 87 ++++- .../src/charts/storyline/interface.ts | 10 +- .../src/charts/storyline/layout.ts | 10 +- .../src/charts/storyline/layouts/bowl.ts | 44 +-- .../src/charts/storyline/layouts/clock.ts | 20 ++ .../src/charts/storyline/layouts/common.ts | 1 + .../src/charts/storyline/layouts/dome.ts | 28 +- .../src/charts/storyline/layouts/wing.ts | 332 ++++++++++++++++++ .../charts/storyline/storyline-transformer.ts | 40 ++- 9 files changed, 525 insertions(+), 47 deletions(-) create mode 100644 packages/vchart-extension/src/charts/storyline/layouts/wing.ts diff --git a/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts b/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts index a07de987d6..a75eefca87 100644 --- a/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts +++ b/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts @@ -12,8 +12,7 @@ const layouts: StorylineLayoutType[] = [ 'clock', 'bowl', 'dome', - 'left-wing', - 'right-wing' + 'wing' ]; const baseData = [ @@ -178,6 +177,46 @@ const createDomeSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ } }); +// bowl:dome 的上下镜像 —— centerImage 贴顶,弧线 + block 在下方 +const createBowlSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ + type: 'storyline', + data: buildData(layout), + layout, + themeColor, + block: { + widthRatio: 0.28, + minWidth: 220, + maxWidth: 320, + height: 300, + padding: 12, + gap: 40, + style: { fill: '#ffffff', stroke: '#d8deea', lineWidth: 1, cornerRadius: 8 } + }, + image: { position: 'left', gap: 12 }, + title: commonTitle, + content: commonContent, + line: commonLine, + centerImage: { + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/center-image.png', + // width: 600, + // height: 600 + // style: { + // scaleX: 2.5, + // scaleY: 2.5 + // // anchor: ['50%', '50%'] + // } + style: { + width: 800, + height: 800, + _debug_bounds: true + // dx: -120, + // dy: -120 + // imageScale: 2 + // anchor: ['50%', '50%'] + } + } +}); + // clock:环绕式时间线,需要 centerImage 作为盘心 const createClockSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ type: 'storyline', @@ -224,11 +263,53 @@ const createDefaultSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ line: commonLine }); +// wing:椭圆弧时间线(参考残奥历史信息图),通过 layout.direction 切换左/右翅膀 +const createWingSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ + type: 'storyline', + padding: [40, 40, 40, 40], + data: buildData(layout), + layout: { type: 'wing', direction: 'left' }, + themeColor, + block: { + widthRatio: 0.32, + minWidth: 280, + maxWidth: 360, + padding: 20, + style: { fill: '#ffffff', stroke: '#d8deea', lineWidth: 1, cornerRadius: 8 } + }, + image: { width: 96, height: 96 }, + title: { + style: { + fontSize: 22, + fontWeight: 800, + lineHeight: 28, + fill: themeColor + } + }, + content: { + style: { + fontSize: 12, + lineHeight: 17, + fill: '#1f2430' + } + }, + line: { + visible: true, + style: { + // 丝带起点窄、终点宽,模拟信息图主脉络 + startWidth: 50, + endWidth: 350 + } as any + } +}); + const specBuilderByLayout: Partial IStorylineSpec>> = { landscape: createLandscapeSpec, portrait: createPortraitSpec, clock: createClockSpec, - dome: createDomeSpec + bowl: createBowlSpec, + dome: createDomeSpec, + wing: createWingSpec }; const createSpec = (layout: StorylineLayoutType): IStorylineSpec => { diff --git a/packages/vchart-extension/src/charts/storyline/interface.ts b/packages/vchart-extension/src/charts/storyline/interface.ts index 9b2b481fe8..aae4d932aa 100644 --- a/packages/vchart-extension/src/charts/storyline/interface.ts +++ b/packages/vchart-extension/src/charts/storyline/interface.ts @@ -13,8 +13,7 @@ export type StorylineLayoutType = | 'clock' | 'bowl' | 'dome' - | 'left-wing' - | 'right-wing' + | 'wing' | 'landscape' | 'portrait' | 'up-ladder' @@ -24,6 +23,7 @@ export type StorylineLayoutType = export type StorylineImagePosition = 'top' | 'left' | 'right' | 'bottom'; export type StorylineLineType = 'line' | 'polyline' | 'curve'; +export type StorylineWingDirection = 'left' | 'right'; export interface IStorylineBlock { id?: StringOrNumber; @@ -51,6 +51,12 @@ export interface IStorylineLayoutOptions { * 对 circular/arc 布局生效,角度单位为度。 */ endAngle?: number; + /** + * 对 wing 布局生效,控制翅膀展开方向。 + * - 'left':圆心锚在画布左侧、弧凸向右展开(默认); + * - 'right':圆心锚在画布右侧、弧凸向左展开。 + */ + direction?: StorylineWingDirection; } export interface IStorylineBlockSpec { diff --git a/packages/vchart-extension/src/charts/storyline/layout.ts b/packages/vchart-extension/src/charts/storyline/layout.ts index 3c63a1a681..2e0a1fb0d1 100644 --- a/packages/vchart-extension/src/charts/storyline/layout.ts +++ b/packages/vchart-extension/src/charts/storyline/layout.ts @@ -164,12 +164,12 @@ const computeBlockPositions = ( case 'dome': centers = arcCenters(count, inner, block, layout, 160, 20); break; - case 'left-wing': - centers = arcCenters(count, inner, block, layout, 250, 110); - break; - case 'right-wing': - centers = arcCenters(count, inner, block, layout, -70, 70); + case 'wing': { + const direction = layout.direction === 'right' ? 'right' : 'left'; + const [s, e] = direction === 'right' ? [110, 250] : [-70, 70]; + centers = arcCenters(count, inner, block, layout, s, e); break; + } case 'landscape': default: centers = lineCenters( diff --git a/packages/vchart-extension/src/charts/storyline/layouts/bowl.ts b/packages/vchart-extension/src/charts/storyline/layouts/bowl.ts index b69f8e70c4..c4a34eb97e 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/bowl.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/bowl.ts @@ -31,8 +31,8 @@ const BOWL_TEXT_BOX_HEIGHT = 300; const BOWL_TITLE_TO_CONTENT_GAP = 4; // 引导线与 title/content 之间的水平间距 const BOWL_TEXT_LEFT_PADDING = 20; -const BOWL_CENTER_IMAGE_WIDTH_RATIO = 0.32; -const BOWL_CENTER_IMAGE_HEIGHT_RATIO = 0.32; +// centerImage 边长相对 inner 短边的比例(强制正方形,避免 cover 模式裁切图片) +const BOWL_CENTER_IMAGE_SIZE_RATIO = 0.4; // 弧线最低点(视觉上的底点)距离 centerImage 底部的距离 const BOWL_ARC_BOTTOM_GAP_FROM_CENTER_IMAGE = 300; @@ -44,8 +44,10 @@ const getBowlCenterImageRect = (spec: IStorylineSpec, ctx: LayoutContext) => { const padding = normalizePadding(spec.block?.padding); const innerWidth = Math.max(width - padding.left - padding.right, 1); const innerHeight = Math.max(height - padding.top - padding.bottom, 1); - const w = Math.max(spec.centerImage?.width ?? innerWidth * BOWL_CENTER_IMAGE_WIDTH_RATIO, 80); - const h = Math.max(spec.centerImage?.height ?? innerHeight * BOWL_CENTER_IMAGE_HEIGHT_RATIO, 60); + // 取 inner 短边作为基准,使 rect 始终为正方形(cover 模式下不会裁切方形图片) + const baseSize = Math.min(innerWidth, innerHeight) * BOWL_CENTER_IMAGE_SIZE_RATIO; + const w = Math.max(spec.centerImage?.width ?? baseSize, 80); + const h = Math.max(spec.centerImage?.height ?? baseSize, 80); const cx = startX + padding.left + innerWidth / 2; // 紧贴顶部,仅保留 spec.block.padding.top 的留白 const top = startY + padding.top; @@ -214,21 +216,6 @@ export const buildBowlCenterImageMark = (spec: IStorylineSpec): IExtensionGroupM lineWidth: 2 } } as ICustomMarkSpec<'symbol'>, - { - type: 'rect', - name: 'storyline-bowl-center-rect', - interactive: false, - style: { - x: (_d: unknown, ctx: LayoutContext) => getBowlCenterImageRect(spec, ctx).x, - y: (_d: unknown, ctx: LayoutContext) => getBowlCenterImageRect(spec, ctx).y, - width: (_d: unknown, ctx: LayoutContext) => getBowlCenterImageRect(spec, ctx).width, - height: (_d: unknown, ctx: LayoutContext) => getBowlCenterImageRect(spec, ctx).height, - cornerRadius: 12, - fill: '#ffffff', - stroke: themeColor, - lineWidth: 2 - } - } as ICustomMarkSpec<'rect'>, hasImage ? ({ type: 'image', @@ -244,8 +231,25 @@ export const buildBowlCenterImageMark = (spec: IStorylineSpec): IExtensionGroupM cornerRadius: 12, repeatX: 'no-repeat', repeatY: 'no-repeat', - imageMode: 'cover', imagePosition: 'center', + // 默认锚点设为 image 中心,让 scaleX/scaleY 从中心缩放 + anchor: (_d: unknown, ctx: LayoutContext) => { + const r = getBowlCenterImageRect(spec, ctx); + return [r.x + r.width / 2, r.y + r.height / 2]; + }, + // 若用户在 style 里覆盖了 width/height,自动追加 dx/dy 让图片仍以 rect 中心为中心 + dx: (_d: unknown, ctx: LayoutContext) => { + const r = getBowlCenterImageRect(spec, ctx); + const userWidth = (spec.centerImage?.style as { width?: number } | undefined)?.width; + const w = typeof userWidth === 'number' ? userWidth : r.width; + return (r.width - w) / 2; + }, + dy: (_d: unknown, ctx: LayoutContext) => { + const r = getBowlCenterImageRect(spec, ctx); + const userHeight = (spec.centerImage?.style as { height?: number } | undefined)?.height; + const h = typeof userHeight === 'number' ? userHeight : r.height; + return (r.height - h) / 2; + }, ...spec.centerImage?.style } } as ICustomMarkSpec<'image'>) diff --git a/packages/vchart-extension/src/charts/storyline/layouts/clock.ts b/packages/vchart-extension/src/charts/storyline/layouts/clock.ts index 11c0c6fe52..f575e2e46b 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/clock.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/clock.ts @@ -147,6 +147,26 @@ export const buildClockCenterImageMark = (spec: IStorylineSpec): IExtensionGroup repeatY: 'no-repeat', imageMode: 'cover', imagePosition: 'center', + // 默认锚点设为 image 中心,让 scaleX/scaleY 从中心缩放 + anchor: (_d: unknown, ctx: LayoutContext) => { + const g = getClockGeometry(spec, ctx); + return [g.cx, g.cy]; + }, + // 若用户在 style 里覆盖了 width/height,自动追加 dx/dy 让图片仍以 rect 中心为中心 + dx: (_d: unknown, ctx: LayoutContext) => { + const g = getClockGeometry(spec, ctx); + const rectW = g.R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO * 2; + const userWidth = (spec.centerImage?.style as { width?: number } | undefined)?.width; + const w = typeof userWidth === 'number' ? userWidth : rectW; + return (rectW - w) / 2; + }, + dy: (_d: unknown, ctx: LayoutContext) => { + const g = getClockGeometry(spec, ctx); + const rectH = g.R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO * 2; + const userHeight = (spec.centerImage?.style as { height?: number } | undefined)?.height; + const h = typeof userHeight === 'number' ? userHeight : rectH; + return (rectH - h) / 2; + }, ...spec.centerImage?.style } } as ICustomMarkSpec<'image'>) diff --git a/packages/vchart-extension/src/charts/storyline/layouts/common.ts b/packages/vchart-extension/src/charts/storyline/layouts/common.ts index e295df9c7c..64c160f868 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/common.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/common.ts @@ -39,6 +39,7 @@ export const isPortrait = (spec: IStorylineSpec) => normalizeLayout(spec.layout) export const isClock = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'clock'; export const isDome = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'dome'; export const isBowl = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'bowl'; +export const isWing = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'wing'; export const getThemeColor = (spec: IStorylineSpec) => spec.themeColor ?? DEFAULT_THEME_COLOR; diff --git a/packages/vchart-extension/src/charts/storyline/layouts/dome.ts b/packages/vchart-extension/src/charts/storyline/layouts/dome.ts index cfc242bec9..0e8c695042 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/dome.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/dome.ts @@ -27,8 +27,8 @@ const DOME_TEXT_BOX_HEIGHT = 300; const DOME_TITLE_TO_CONTENT_GAP = 4; // 引导线与 title/content 之间的水平间距 const DOME_TEXT_LEFT_PADDING = 20; -const DOME_CENTER_IMAGE_WIDTH_RATIO = 0.32; -const DOME_CENTER_IMAGE_HEIGHT_RATIO = 0.32; +// centerImage 边长相对 inner 短边的比例(强制正方形,避免 cover 模式裁切图片) +const DOME_CENTER_IMAGE_SIZE_RATIO = 0.4; // 弧线最高点(视觉上的顶点)距离 centerImage 顶部的距离 const DOME_ARC_TOP_GAP_FROM_CENTER_IMAGE = 300; @@ -40,8 +40,10 @@ const getDomeCenterImageRect = (spec: IStorylineSpec, ctx: LayoutContext) => { const padding = normalizePadding(spec.block?.padding); const innerWidth = Math.max(width - padding.left - padding.right, 1); const innerHeight = Math.max(height - padding.top - padding.bottom, 1); - const w = Math.max(spec.centerImage?.width ?? innerWidth * DOME_CENTER_IMAGE_WIDTH_RATIO, 80); - const h = Math.max(spec.centerImage?.height ?? innerHeight * DOME_CENTER_IMAGE_HEIGHT_RATIO, 60); + // 取 inner 短边作为基准,使 rect 始终为正方形(cover 模式下不会裁切方形图片) + const baseSize = Math.min(innerWidth, innerHeight) * DOME_CENTER_IMAGE_SIZE_RATIO; + const w = Math.max(spec.centerImage?.width ?? baseSize, 80); + const h = Math.max(spec.centerImage?.height ?? baseSize, 80); const cx = startX + padding.left + innerWidth / 2; // 紧贴底部,仅保留 spec.block.padding.bottom 的留白 const top = startY + padding.top + innerHeight - h; @@ -248,6 +250,24 @@ export const buildDomeCenterImageMark = (spec: IStorylineSpec): IExtensionGroupM repeatY: 'no-repeat', imageMode: 'cover', imagePosition: 'center', + // 默认锚点设为 image 中心,让 scaleX/scaleY 从中心缩放 + anchor: (_d: unknown, ctx: LayoutContext) => { + const r = getDomeCenterImageRect(spec, ctx); + return [r.x + r.width / 2, r.y + r.height / 2]; + }, + // 若用户在 style 里覆盖了 width/height,自动追加 dx/dy 让图片仍以 rect 中心为中心 + dx: (_d: unknown, ctx: LayoutContext) => { + const r = getDomeCenterImageRect(spec, ctx); + const userWidth = (spec.centerImage?.style as { width?: number } | undefined)?.width; + const w = typeof userWidth === 'number' ? userWidth : r.width; + return (r.width - w) / 2; + }, + dy: (_d: unknown, ctx: LayoutContext) => { + const r = getDomeCenterImageRect(spec, ctx); + const userHeight = (spec.centerImage?.style as { height?: number } | undefined)?.height; + const h = typeof userHeight === 'number' ? userHeight : r.height; + return (r.height - h) / 2; + }, ...spec.centerImage?.style } } as ICustomMarkSpec<'image'>) diff --git a/packages/vchart-extension/src/charts/storyline/layouts/wing.ts b/packages/vchart-extension/src/charts/storyline/layouts/wing.ts new file mode 100644 index 0000000000..ef693ebc45 --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/layouts/wing.ts @@ -0,0 +1,332 @@ +import type { IExtensionGroupMarkSpec } from '@visactor/vchart'; +import { LayoutZIndex } from '@visactor/vchart'; +import type { IStorylineBlock, IStorylineSpec, StorylineWingDirection } from '../interface'; +import { + type ICustomMarkSpec, + type LayoutContext, + type StorylinePoint, + buildRichContent, + getRegionGeometry, + getThemeColor, + normalizeLayout, + normalizePadding, + omitImageLayoutSpec, + withAlpha +} from './common'; + +// wing 布局:参考残奥时间线信息图 +// - 主脉络为椭圆弧的「翅膀」造型,可通过 direction 配置左右朝向 +// - direction: 'left' → 圆心锚在画布左侧,弧凸向右展开(默认) +// - direction: 'right' → 圆心锚在画布右侧,弧凸向左展开 +// - 圆形 image 嵌在弧线上(中心位于弧线) +// - title(年份感大字 + 主题色) + content 在 image 一侧水平展开 +// - 左右交替(弧线左侧 / 右侧)让节点错落 +const WING_BLOCK_IMAGE_SIZE = 96; +const WING_TEXT_GAP_FROM_IMAGE = 14; +const WING_TITLE_LINE_HEIGHT = 26; +const WING_TITLE_FONT_SIZE = 20; +const WING_CONTENT_LINE_HEIGHT = 17; +const WING_CONTENT_FONT_SIZE = 12; +// title + content 区域宽度 +const WING_TEXT_BOX_WIDTH = 240; +// title + content 区域总高度 +const WING_TEXT_BOX_HEIGHT = 110; +const WING_TITLE_TO_CONTENT_GAP = 4; + +const getWingDirection = (spec: IStorylineSpec): StorylineWingDirection => { + return normalizeLayout(spec.layout).direction ?? 'left'; +}; + +/** + * 计算 wing 弧线的几何参数: + * - direction='left':圆心位于 inner 左侧,采样区间 -70°→70°(cos>0),弧线点位于圆心右侧; + * - direction='right':圆心位于 inner 右侧,采样区间 110°→250°(cos<0),弧线点位于圆心左侧。 + */ +const getWingArcGeometry = (spec: IStorylineSpec, ctx: LayoutContext) => { + const { width, height, startX, startY } = getRegionGeometry(ctx); + const padding = normalizePadding(spec.block?.padding); + const innerWidth = Math.max(width - padding.left - padding.right, 1); + const innerHeight = Math.max(height - padding.top - padding.bottom, 1); + const layoutOpt = normalizeLayout(spec.layout); + const direction = getWingDirection(spec); + const defaultStart = direction === 'right' ? 110 : -70; + const defaultEnd = direction === 'right' ? 250 : 70; + const startAngle = layoutOpt.startAngle ?? defaultStart; + const endAngle = layoutOpt.endAngle ?? defaultEnd; + const ratio = layoutOpt.radiusRatio ?? 0.92; + const ry = (innerHeight / 2) * ratio; + const rx = innerWidth * 0.6 * ratio; + // 左翅膀锚画布左侧,右翅膀锚画布右侧 + const cx = direction === 'right' ? startX + padding.left + innerWidth - rx * 0.1 : startX + padding.left + rx * 0.1; + const cy = startY + padding.top + innerHeight / 2; + return { cx, cy, rx, ry, startAngle, endAngle }; +}; + +/** + * 沿弧采样 block 中心 —— image 的圆心直接在弧线上,与时间线视觉对齐。 + */ +const getWingBlockCenter = (spec: IStorylineSpec, ctx: LayoutContext, index: number): StorylinePoint => { + const arc = getWingArcGeometry(spec, ctx); + const count = spec.data?.length ?? 0; + if (count <= 0) { + return { x: arc.cx, y: arc.cy }; + } + const t = count === 1 ? 0.5 : index / (count - 1); + const angle = ((arc.startAngle + (arc.endAngle - arc.startAngle) * t) / 180) * Math.PI; + return { + x: arc.cx + Math.cos(angle) * arc.rx, + y: arc.cy + Math.sin(angle) * arc.ry + }; +}; + +/** + * 节点文字侧向: + * - 左翅膀(弧凸向右):偶数节点的文字排在弧线左侧; + * - 右翅膀(弧凸向左):偶数节点的文字排在弧线右侧(即镜像)。 + */ +const isTextOnLeft = (spec: IStorylineSpec, index: number) => { + const direction = getWingDirection(spec); + return direction === 'right' ? index % 2 === 1 : index % 2 === 0; +}; + +/** + * 主脉络曲线 mark:贯穿所有 block 的椭圆弧。 + * 用变宽的 filled path 模拟"丝带"——起点窄、终点宽,与信息图视觉一致。 + * 默认展示;用户可通过 spec.line.visible = false 关闭。 + */ +export const buildWingArcMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + if (spec.line?.visible === false) { + return null; + } + const themeColor = getThemeColor(spec); + const lineStyle = (spec.line?.style ?? {}) as Record; + const startWidth = Math.max(Number(lineStyle.startWidth ?? 2), 0.5); + const endWidth = Math.max(Number(lineStyle.endWidth ?? lineStyle.lineWidth ?? 18), startWidth); + return { + type: 'group' as any, + name: 'storyline-wing-arc', + zIndex: LayoutZIndex.Mark, + children: [ + { + type: 'path', + name: 'storyline-wing-arc-path', + interactive: false, + style: { + stroke: false, + lineWidth: 0, + fill: (lineStyle.fill as string) ?? (lineStyle.stroke as string) ?? themeColor, + opacity: 0.95, + path: (_d: unknown, ctx: LayoutContext) => { + const arc = getWingArcGeometry(spec, ctx); + const span = arc.endAngle - arc.startAngle; + const samples = 96; + const pts: { x: number; y: number; nx: number; ny: number; w: number }[] = []; + for (let i = 0; i <= samples; i++) { + const t = i / samples; + const angle = ((arc.startAngle + span * t) / 180) * Math.PI; + const cx = arc.cx + Math.cos(angle) * arc.rx; + const cy = arc.cy + Math.sin(angle) * arc.ry; + const nxRaw = Math.cos(angle) / arc.rx; + const nyRaw = Math.sin(angle) / arc.ry; + const nLen = Math.hypot(nxRaw, nyRaw) || 1; + pts.push({ + x: cx, + y: cy, + nx: nxRaw / nLen, + ny: nyRaw / nLen, + w: startWidth + (endWidth - startWidth) * t + }); + } + const segments: string[] = []; + for (let i = 0; i < pts.length; i++) { + const p = pts[i]; + const x = p.x + p.nx * (p.w / 2); + const y = p.y + p.ny * (p.w / 2); + segments.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`); + } + for (let i = pts.length - 1; i >= 0; i--) { + const p = pts[i]; + const x = p.x - p.nx * (p.w / 2); + const y = p.y - p.ny * (p.w / 2); + segments.push(`L ${x.toFixed(2)} ${y.toFixed(2)}`); + } + segments.push('Z'); + return segments.join(' '); + } + } + } as ICustomMarkSpec<'path'> + ] + }; +}; + +const getWingBlockMetrics = (spec: IStorylineSpec, index: number) => { + const titleFontSize = Number((spec.title?.style as Record)?.fontSize ?? WING_TITLE_FONT_SIZE); + const titleLineHeight = Number( + (spec.title?.style as Record)?.lineHeight ?? + Math.max(WING_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.3)) + ); + const contentFontSize = Number((spec.content?.style as Record)?.fontSize ?? WING_CONTENT_FONT_SIZE); + const contentLineHeight = Number( + (spec.content?.style as Record)?.lineHeight ?? WING_CONTENT_LINE_HEIGHT + ); + const titleToContentGap = WING_TITLE_TO_CONTENT_GAP; + const textHeight = WING_TEXT_BOX_HEIGHT; + const contentHeight = Math.max(textHeight - titleLineHeight - titleToContentGap, contentLineHeight); + + const imageWidth = spec.image?.width ?? WING_BLOCK_IMAGE_SIZE; + const imageHeight = spec.image?.height ?? WING_BLOCK_IMAGE_SIZE; + const imageBox = { + x: -imageWidth / 2, + y: -imageHeight / 2, + width: imageWidth, + height: imageHeight + }; + + const onLeft = isTextOnLeft(spec, index); + const textWidth = WING_TEXT_BOX_WIDTH; + const textX = onLeft + ? -imageWidth / 2 - WING_TEXT_GAP_FROM_IMAGE - textWidth + : imageWidth / 2 + WING_TEXT_GAP_FROM_IMAGE; + const textY = -textHeight / 2; + const textBox = { x: textX, y: textY, width: textWidth, height: textHeight }; + const contentBox = { + x: textX, + y: textY + titleLineHeight + titleToContentGap, + width: textWidth, + height: contentHeight + }; + return { + onLeft, + titleFontSize, + titleLineHeight, + contentFontSize, + contentLineHeight, + imageBox, + textBox, + contentBox + }; +}; + +export const buildWingBlockMark = ( + spec: IStorylineSpec, + block: IStorylineBlock, + index: number +): IExtensionGroupMarkSpec => { + const hasImage = !!block.image; + const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; + const themeColor = getThemeColor(spec); + const metrics = getWingBlockMetrics(spec, index); + + return { + type: 'group' as any, + id: `storyline-block-${block.id ?? index}`, + name: `storyline-block-${index}`, + zIndex: LayoutZIndex.Mark + 1, + style: { + x: (_d: unknown, ctx: LayoutContext) => getWingBlockCenter(spec, ctx, index).x, + y: (_d: unknown, ctx: LayoutContext) => getWingBlockCenter(spec, ctx, index).y + }, + children: [ + { + type: 'symbol', + name: `storyline-block-image-halo-${index}`, + interactive: false, + style: { + x: 0, + y: 0, + size: Math.max(metrics.imageBox.width, metrics.imageBox.height) + 12, + symbolType: 'circle', + fill: withAlpha(themeColor, 0.18), + stroke: themeColor, + lineWidth: 1.5 + } + } as ICustomMarkSpec<'symbol'>, + hasImage + ? ({ + type: 'image', + name: `storyline-block-image-${index}`, + interactive: false, + ...omitImageLayoutSpec(spec.image), + style: { + x: metrics.imageBox.x, + y: metrics.imageBox.y, + width: metrics.imageBox.width, + height: metrics.imageBox.height, + image: block.image, + cornerRadius: Math.min(metrics.imageBox.width, metrics.imageBox.height) / 2, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'cover', + imagePosition: 'center', + stroke: '#ffffff', + lineWidth: 3, + ...spec.image?.style + } + } as ICustomMarkSpec<'image'>) + : ({ + type: 'rect', + name: `storyline-block-image-bg-${index}`, + interactive: false, + style: { + x: metrics.imageBox.x, + y: metrics.imageBox.y, + width: metrics.imageBox.width, + height: metrics.imageBox.height, + cornerRadius: Math.min(metrics.imageBox.width, metrics.imageBox.height) / 2, + fill: '#ffffff', + stroke: themeColor, + lineWidth: 2 + } + } as ICustomMarkSpec<'rect'>), + block.title + ? ({ + type: 'text', + name: `storyline-block-title-${index}`, + interactive: false, + zIndex: LayoutZIndex.Mark + 10, + ...spec.title, + style: { + x: metrics.onLeft ? metrics.textBox.x + metrics.textBox.width : metrics.textBox.x, + y: metrics.textBox.y, + text: block.title, + maxLineWidth: metrics.textBox.width, + fontSize: metrics.titleFontSize, + lineHeight: metrics.titleLineHeight, + fontWeight: 'bold', + fill: themeColor, + textAlign: metrics.onLeft ? 'right' : 'left', + textBaseline: 'top', + ...spec.title?.style + } + } as ICustomMarkSpec<'text'>) + : null, + contentText.length + ? ({ + type: 'text', + name: `storyline-block-content-${index}`, + interactive: false, + zIndex: LayoutZIndex.Mark + 10, + ...spec.content, + textType: 'rich', + style: { + x: metrics.onLeft ? metrics.contentBox.x + metrics.contentBox.width : metrics.contentBox.x, + y: metrics.contentBox.y, + width: metrics.contentBox.width, + height: metrics.contentBox.height, + maxLineWidth: metrics.contentBox.width, + heightLimit: metrics.contentBox.height, + text: buildRichContent(contentText, spec), + fontSize: WING_CONTENT_FONT_SIZE, + lineHeight: WING_CONTENT_LINE_HEIGHT, + textAlign: metrics.onLeft ? 'right' : 'left', + textBaseline: 'top', + wordBreak: 'break-word', + ellipsis: '...', + fill: '#1f2430', + ...spec.content?.style + } + } as ICustomMarkSpec<'text'>) + : null + ].filter(Boolean) as ICustomMarkSpec[] + }; +}; diff --git a/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts b/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts index cde2949422..31177f4552 100644 --- a/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts +++ b/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts @@ -1,12 +1,13 @@ import { CommonChartSpecTransformer, type IExtensionGroupMarkSpec } from '@visactor/vchart'; import type { IStorylineBlock, IStorylineSpec } from './interface'; -import { isBowl, isClock, isDome, isLandscape, isPortrait } from './layouts/common'; +import { isBowl, isClock, isDome, isLandscape, isPortrait, isWing } from './layouts/common'; import { buildClockArcMark, buildClockBlockMark, buildClockCenterImageMark } from './layouts/clock'; import { buildDefaultBlockMark, buildDefaultLineMark } from './layouts/default'; import { buildLandscapeBlockMark, buildLandscapeConnectingCurve } from './layouts/landscape'; import { buildPortraitAxisMark, buildPortraitBlockMark } from './layouts/portrait'; import { buildDomeArcMark, buildDomeBlockMark, buildDomeCenterImageMark } from './layouts/dome'; import { buildBowlArcMark, buildBowlBlockMark, buildBowlCenterImageMark } from './layouts/bowl'; +import { buildWingArcMark, buildWingBlockMark } from './layouts/wing'; export class StorylineChartSpecTransformer extends CommonChartSpecTransformer { transformSpec(spec: any): void { @@ -28,33 +29,37 @@ export class StorylineChartSpecTransformer extends CommonChartSpecTransformer { - const DEFAULT_BOTTOM = 100; - const DEFAULT_OTHER = 20; + const LARGE = 100; + const SMALL = 20; + const bowl = isBowl(spec as IStorylineSpec); + const defaultTop = bowl ? LARGE : SMALL; + const defaultBottom = bowl ? SMALL : LARGE; const p = spec.padding; if (p === undefined || p === null) { - spec.padding = [DEFAULT_OTHER, DEFAULT_OTHER, DEFAULT_BOTTOM, DEFAULT_OTHER]; + spec.padding = [defaultTop, SMALL, defaultBottom, SMALL]; return; } if (typeof p === 'number') { - spec.padding = [p, p, Math.max(p, DEFAULT_BOTTOM), p]; + spec.padding = bowl ? [Math.max(p, LARGE), p, p, p] : [p, p, Math.max(p, LARGE), p]; return; } if (Array.isArray(p)) { - const [t = DEFAULT_OTHER, r = DEFAULT_OTHER, b, l = DEFAULT_OTHER] = p; - spec.padding = [t, r, b ?? DEFAULT_BOTTOM, l]; + const [t, r = SMALL, b, l = SMALL] = p; + spec.padding = [t ?? defaultTop, r, b ?? defaultBottom, l]; return; } if (typeof p === 'object') { spec.padding = { - top: p.top ?? DEFAULT_OTHER, - right: p.right ?? DEFAULT_OTHER, - bottom: p.bottom ?? DEFAULT_BOTTOM, - left: p.left ?? DEFAULT_OTHER + top: p.top ?? defaultTop, + right: p.right ?? SMALL, + bottom: p.bottom ?? defaultBottom, + left: p.left ?? SMALL }; } }; @@ -89,6 +94,12 @@ const buildStorylineMarks = (spec: IStorylineSpec) => { const centerImageMark = buildClockCenterImageMark(spec); return [ringsMark, ...blockMarks, centerImageMark].filter(Boolean) as IExtensionGroupMarkSpec[]; } + // wing:椭圆弧脉络 + 弧线上的圆形 image + 左右交替排列的 title/content; + // 通过 layout.direction 控制翅膀朝向('left' | 'right') + if (isWing(spec)) { + const arcMark = buildWingArcMark(spec); + return [arcMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; + } return [lineMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; }; @@ -121,6 +132,9 @@ const buildBlockMark = (spec: IStorylineSpec, block: IStorylineBlock, index: num if (isClock(spec)) { return buildClockBlockMark(spec, block, index); } + if (isWing(spec)) { + return buildWingBlockMark(spec, block, index); + } return buildDefaultBlockMark(spec, block, index); }; From b9479b04ad1c3dfc7b7e8a6bcd4a5f31d6d01861 Mon Sep 17 00:00:00 2001 From: skie1997 Date: Mon, 15 Jun 2026 15:24:43 +0800 Subject: [PATCH 3/8] feat: enhance layout effect --- .../runtime/browser/test-page/storyline.ts | 221 ++++++--- .../src/charts/storyline/interface.ts | 49 +- .../src/charts/storyline/layout.ts | 80 ++-- .../storyline/layouts/{bowl.ts => arc.ts} | 220 +++++---- .../src/charts/storyline/layouts/clock.ts | 14 +- .../src/charts/storyline/layouts/common.ts | 4 +- .../src/charts/storyline/layouts/default.ts | 8 +- .../src/charts/storyline/layouts/dome.ts | 445 ------------------ .../src/charts/storyline/layouts/ladder.ts | 367 +++++++++++++++ .../src/charts/storyline/layouts/landscape.ts | 8 +- .../src/charts/storyline/layouts/portrait.ts | 49 +- .../src/charts/storyline/layouts/wing.ts | 42 +- .../charts/storyline/storyline-transformer.ts | 120 +++-- 13 files changed, 859 insertions(+), 768 deletions(-) rename packages/vchart-extension/src/charts/storyline/layouts/{bowl.ts => arc.ts} (62%) delete mode 100644 packages/vchart-extension/src/charts/storyline/layouts/dome.ts create mode 100644 packages/vchart-extension/src/charts/storyline/layouts/ladder.ts diff --git a/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts b/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts index a75eefca87..7f780b751f 100644 --- a/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts +++ b/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts @@ -2,18 +2,9 @@ import { VChart } from '@visactor/vchart'; import { registerStorylineChart } from '../../../../src'; import type { IStorylineSpec, StorylineLayoutType } from '../../../../src/charts/storyline'; -const layouts: StorylineLayoutType[] = [ - 'landscape', - 'portrait', - 'up-ladder', - 'down-ladder', - 'pulse', - 'spiral', - 'clock', - 'bowl', - 'dome', - 'wing' -]; +const layouts: StorylineLayoutType[] = ['landscape', 'portrait', 'ladder', 'spiral', 'clock', 'arc', 'wing']; + +const SUB_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-2022.png'; const baseData = [ { @@ -22,7 +13,8 @@ const baseData = [ content: 'Collect the first signal and frame the story. Capture every relevant detail from the source material ' + 'so the audience can reconstruct the same context the author had when starting the analysis.', - image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL }, { id: 'group', @@ -30,7 +22,8 @@ const baseData = [ content: 'Arrange related facts into a compact block, removing duplicates and aligning each fragment ' + 'to the central theme so readers can scan supporting evidence at a glance without losing context.', - image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL }, { id: 'connect', @@ -38,7 +31,8 @@ const baseData = [ content: 'Draw the reading path between blocks. Use repeating motifs, parallel sentence structures ' + 'and visual cues to establish a continuous flow that walks the reader from premise to conclusion.', - image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL }, { id: 'emphasize', @@ -46,7 +40,8 @@ const baseData = [ content: 'Use image, title, and copy as one visual unit. Highlight the most important facts with typography ' + 'weight, color contrast or motion so the eye instinctively returns to them while scanning.', - image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL }, { id: 'resolve', @@ -54,7 +49,8 @@ const baseData = [ content: 'End with a clear takeaway. Summarize the lesson, point out the next decision the audience ' + 'should make and remove any ambiguity so the story closes with a satisfying, actionable conclusion.', - image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL } ]; @@ -146,18 +142,18 @@ const createPortraitSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ gap: 40, style: { fill: '#ffffff', stroke: '#d8deea', lineWidth: 1, cornerRadius: 8 } }, - image: { gap: 0 }, + image: { gap: 0, showBackground: true }, title: commonTitle, content: commonContent, line: commonLine }); -// bowl:顶部 50 / 底部 10 留白以承载弧线 + centerImage -const createDomeSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ +// arc:弧形布局,通过 direction 切换 dome('up')/ bowl('down') +const createArcSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ type: 'storyline', padding: [50, 20, 100, 20], data: buildData(layout), - layout, + layout: { type: 'arc', direction: 'down' }, themeColor, block: { widthRatio: 0.28, @@ -177,12 +173,68 @@ const createDomeSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ } }); -// bowl:dome 的上下镜像 —— centerImage 贴顶,弧线 + block 在下方 -const createBowlSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ +// clock:环绕式时间线,需要 centerImage 作为盘心 +const createClockSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ type: 'storyline', - data: buildData(layout), - layout, - themeColor, + width: 1600, + height: 700, + padding: [20, 20, 50, 20], + layout: 'clock', + themeColor: '#C8102E', + background: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + data: [ + { + id: 'uruguay-1930', + title: '首届世界杯诞生', + content: + '1930年7月,国际足联首届世界杯在乌拉圭蒙得维的亚开幕,仅有十三支球队参赛。' + + '东道主乌拉圭借助世纪球场坐镇,决赛中以4比2逆转近邻阿根廷,捧起了雷米特金杯。' + + '乌拉圭队队长纳萨齐高举奖杯的画面,从此奠定了世界杯作为全球足球最高荣誉的象征意义。', + image: 'assets/node-uruguay-1930.png' + }, + { + id: 'brazil-1958', + title: '贝利天才登场', + content: + '1958年瑞典世界杯成为足球新王登基的舞台。年仅十七岁的贝利首次代表巴西出战,在四分之一决赛对威尔士贡献关键进球,半决赛对法国上演帽子戏法,决赛对东道主瑞典再度梅开二度,帮助巴西首夺世界杯冠军。', + image: 'assets/node-brazil-1958.png' + }, + { + id: 'mexico-1986', + title: '马拉多纳神迹', + content: + '1986年墨西哥世界杯由马拉多纳一人定义。四分之一决赛对英格兰,' + + '他先用左手将球送入网窝制造『上帝之手』,紧接着又从中圈带球连过五人攻入世纪进球,' + + '让阿根廷在马岛战争阴影下挣得舆论高地。半决赛对比利时再献两粒精彩入球,最终阿根廷3比2夺冠。', + image: 'assets/node-mexico-1986.png' + }, + { + id: 'france-1998', + title: '齐祖之夜法兰西', + content: + '1998年法国世界杯由东道主自己谱写童话。决赛在圣丹尼新落成的法兰西大球场进行,' + + '齐达内两次起跳头槌破门,将卫冕冠军巴西打懵,最终法国3比0大胜首夺世界杯。' + + '比赛终场哨响时,香榭丽舍大街涌入百万球迷,蓝白红的海洋与齐达内剪影一同映在凯旋门上。', + image: 'assets/node-france-1998.png' + }, + { + id: 'germany-2014', + title: '战车碾过马拉卡纳', + content: + '2014年巴西世界杯德国队成为最大赢家。半决赛德国在贝洛奥里藏特7比1血洗东道主巴西,决赛在传奇的马拉卡纳球场进行,加时赛第113分钟,戈策胸停凌空抽射打进绝杀,德国时隔24年再夺世界杯。', + image: 'assets/node-germany-2014.png' + }, + { + id: 'qatar-2022', + title: '梅西终圆封王梦', + content: + '2022年卡塔尔世界杯成为首届在中东和北半球冬季举行的世界杯。决赛在卢赛尔体育场进行,' + + '阿根廷与法国上演被誉为史上最经典的对决。梅西梅开二度,' + + '姆巴佩则上演世界杯决赛六十五年来首个帽子戏法,常规及加时赛战成3比3。' + + '点球大战中阿根廷4比2取胜。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + } + ], block: { widthRatio: 0.28, minWidth: 220, @@ -190,58 +242,54 @@ const createBowlSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ height: 300, padding: 12, gap: 40, - style: { fill: '#ffffff', stroke: '#d8deea', lineWidth: 1, cornerRadius: 8 } + style: { + fill: 'rgba(255,255,255,0.92)', + stroke: 'rgba(200,16,46,0.2)', + lineWidth: 1, + cornerRadius: 8 + } }, - image: { position: 'left', gap: 12 }, - title: commonTitle, - content: commonContent, - line: commonLine, - centerImage: { - image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/center-image.png', - // width: 600, - // height: 600 - // style: { - // scaleX: 2.5, - // scaleY: 2.5 - // // anchor: ['50%', '50%'] - // } + image: { + gap: 12, + width: 300, + height: 300 + }, + title: { style: { - width: 800, - height: 800, - _debug_bounds: true - // dx: -120, - // dy: -120 - // imageScale: 2 - // anchor: ['50%', '50%'] + fontSize: 15, + fontWeight: 800, + fill: '#C8102E' + } + }, + content: { + style: { + fontSize: 12, + lineHeight: 17, + fill: '#4a4a4a' + } + }, + line: { + type: 'line', + showArrow: true, + style: { + lineWidth: 2, + lineCap: 'round', + lineJoin: 'round', + lineDash: [8, 4] } - } -}); - -// clock:环绕式时间线,需要 centerImage 作为盘心 -const createClockSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ - type: 'storyline', - padding: 20, - data: buildData(layout), - layout, - themeColor, - block: { - widthRatio: 0.28, - minWidth: 220, - maxWidth: 320, - padding: 12, - gap: 40, - style: { fill: '#ffffff', stroke: '#d8deea', lineWidth: 1, cornerRadius: 8 } }, - image: { position: 'left', gap: 12 }, - title: commonTitle, - content: commonContent, - line: commonLine, centerImage: { - image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + // image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + width: 360, + height: 360, + style: { + width: 360, + height: 360 + } } }); -// 默认 / clock / ladder / pulse / spiral / dome / wing 等布局共用一份 spec +// 默认 / ladder / spiral 等布局共用一份 spec const createDefaultSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ type: 'storyline', padding: 20, @@ -268,7 +316,7 @@ const createWingSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ type: 'storyline', padding: [40, 40, 40, 40], data: buildData(layout), - layout: { type: 'wing', direction: 'left' }, + layout: { type: 'wing', direction: 'right' }, themeColor, block: { widthRatio: 0.32, @@ -303,13 +351,36 @@ const createWingSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ } }); +// ladder:参考 Bauhaus 信息图 —— 中央倾斜大字 headline + 两侧错落 block +// 通过 layout.direction('up' | 'down')控制对角线方向 +const createLadderSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ + type: 'storyline', + data: buildData(layout), + layout: { type: 'ladder', direction: 'up', headline: 'bauhaus' }, + themeColor, + block: { + widthRatio: 0.26, + minWidth: 200, + maxWidth: 280, + height: 132, + padding: 12, + gap: 24, + style: { fill: '#ffffff', stroke: '#d8deea', lineWidth: 1, cornerRadius: 8 } + }, + image: { position: 'left', gap: 12 }, + title: commonTitle, + content: commonContent, + // headline 已是视觉轴,关闭 block 间默认连线 + line: { visible: false } +}); + const specBuilderByLayout: Partial IStorylineSpec>> = { landscape: createLandscapeSpec, portrait: createPortraitSpec, clock: createClockSpec, - bowl: createBowlSpec, - dome: createDomeSpec, - wing: createWingSpec + arc: createArcSpec, + wing: createWingSpec, + ladder: createLadderSpec }; const createSpec = (layout: StorylineLayoutType): IStorylineSpec => { @@ -353,7 +424,7 @@ const run = () => { window.vchart = cs; }; - select.value = layouts[6]; + select.value = layouts[2]; render(select.value as StorylineLayoutType); select.addEventListener('change', () => { diff --git a/packages/vchart-extension/src/charts/storyline/interface.ts b/packages/vchart-extension/src/charts/storyline/interface.ts index aae4d932aa..97b66fdc86 100644 --- a/packages/vchart-extension/src/charts/storyline/interface.ts +++ b/packages/vchart-extension/src/charts/storyline/interface.ts @@ -9,27 +9,25 @@ import type { StringOrNumber } from '@visactor/vchart'; -export type StorylineLayoutType = - | 'clock' - | 'bowl' - | 'dome' - | 'wing' - | 'landscape' - | 'portrait' - | 'up-ladder' - | 'down-ladder' - | 'pulse' - | 'spiral'; +export type StorylineLayoutType = 'clock' | 'arc' | 'wing' | 'landscape' | 'portrait' | 'ladder' | 'spiral'; export type StorylineImagePosition = 'top' | 'left' | 'right' | 'bottom'; export type StorylineLineType = 'line' | 'polyline' | 'curve'; export type StorylineWingDirection = 'left' | 'right'; +export type StorylineLadderDirection = 'up' | 'down'; +export type StorylineArcDirection = 'up' | 'down'; export interface IStorylineBlock { id?: StringOrNumber; title?: string; content?: string | string[]; image?: string | HTMLImageElement | HTMLCanvasElement; + /** + * 绘制在主 image 背后的装饰图(如 portrait 布局的错位 shadow image)。 + * 仅在对应布局的 image.showBackground 为 true 时生效。 + * 若未配置,则不会绘制装饰 image。 + */ + subImage?: string | HTMLImageElement | HTMLCanvasElement; datum?: unknown; } @@ -52,11 +50,17 @@ export interface IStorylineLayoutOptions { */ endAngle?: number; /** - * 对 wing 布局生效,控制翅膀展开方向。 - * - 'left':圆心锚在画布左侧、弧凸向右展开(默认); - * - 'right':圆心锚在画布右侧、弧凸向左展开。 + * 方向控制: + * - wing 布局:'left' | 'right',圆心锚位置; + * - ladder 布局:'up' | 'down','up' 表示左下→右上对角线(默认),'down' 表示左上→右下对角线; + * - arc 布局:'up' | 'down','up' 表示穹顶(centerImage 贴底,弧线在上方),'down' 表示碗形(centerImage 贴顶,弧线在下方)。 + */ + direction?: StorylineWingDirection | StorylineLadderDirection | StorylineArcDirection; + /** + * 对 ladder 布局生效:贯穿画布的倾斜大字 headline。 + * 缺省时使用占位文本。倾斜方向自动跟随对角线。 */ - direction?: StorylineWingDirection; + headline?: string; } export interface IStorylineBlockSpec { @@ -67,6 +71,11 @@ export interface IStorylineBlockSpec { height?: number; padding?: number | [number, number, number, number]; gap?: number; + /** + * 是否展示 block 背后的卡片背景 rect(白底 + 描边 + 阴影)。 + * 仅 up-ladder 等少数布局支持,默认 false(不展示)。 + */ + showBackground?: boolean; style?: Partial; } @@ -75,6 +84,16 @@ export interface IStorylineImageSpec extends IMarkSpec { height?: number; position?: StorylineImagePosition; gap?: number; + /** + * 是否展示 image 背后的装饰图元(halo / shadow / 背景 rect 等)。 + * 不同布局对应的装饰图元不同: + * - wing: 圆形 halo symbol + * - portrait: 错位 shadow image + mask + * - clock: 楔形/圆形背景 rect + * - dome / bowl / landscape: image-bg(无图时的占位 rect 不受此开关影响) + * 默认 false(不展示)。 + */ + showBackground?: boolean; } export interface IStorylineCenterImageSpec extends IMarkSpec { diff --git a/packages/vchart-extension/src/charts/storyline/layout.ts b/packages/vchart-extension/src/charts/storyline/layout.ts index 2e0a1fb0d1..c3ee9c5d1a 100644 --- a/packages/vchart-extension/src/charts/storyline/layout.ts +++ b/packages/vchart-extension/src/charts/storyline/layout.ts @@ -131,39 +131,54 @@ const computeBlockPositions = ( inner.y + inner.height - block.height / 2 ); break; - case 'up-ladder': - centers = lineCenters( - count, - inner.x + block.width / 2, - inner.y + inner.height - block.height / 2, - inner.x + inner.width - block.width / 2, - inner.y + block.height / 2 - ); - break; - case 'down-ladder': - centers = lineCenters( - count, - inner.x + block.width / 2, - inner.y + block.height / 2, - inner.x + inner.width - block.width / 2, - inner.y + inner.height - block.height / 2 - ); - break; - case 'pulse': - centers = alternatingHorizontalCenters(count, inner, block, gap); + case 'ladder': { + // 沿对角线均匀采样 anchor 点,偶/奇 index 沿对角线"法向"做左/右偏移。 + // direction = 'up' (默认):左下 → 右上;direction = 'down':左上 → 右下。 + const isDown = layout.direction === 'down'; + const x0 = inner.x + block.width / 2; + const x1 = inner.x + inner.width - block.width / 2; + const yTop = inner.y + block.height / 2; + const yBot = inner.y + inner.height - block.height / 2; + const y0 = isDown ? yTop : yBot; + const y1 = isDown ? yBot : yTop; + const anchors = lineCenters(count, x0, y0, x1, y1); + // 对角线方向向量 + const dx = x1 - x0; + const dy = y1 - y0; + const len = Math.hypot(dx, dy) || 1; + // 法向单位向量 + const nx = -dy / len; + const ny = dx / len; + // 偏移量:与 headline fontSize 联动 —— 与 ladder.ts 中保持同一公式 + // headline fontSize = clamp(innerHeight * 0.42, 80, 240) + // 偏移量 = headline fontSize * 1.2,让 block 与 headline 大字之间留出充足留白 + const headlineFontSize = Math.max(80, Math.min(240, Math.round(inner.height * 0.42))); + const offset = headlineFontSize * 1.2; + centers = anchors.map((p, i) => { + // 偶数 index → 法向 +;奇数 index → 法向 - + const sign = i % 2 === 0 ? 1 : -1; + return { + x: p.x + nx * offset * sign, + y: p.y + ny * offset * sign + }; + }); break; + } case 'spiral': centers = alternatingVerticalCenters(count, inner, block, gap); break; case 'clock': centers = circularCenters(count, viewBox, block, padding, layout); break; - case 'bowl': - centers = arcCenters(count, inner, block, layout, 200, 340); - break; - case 'dome': - centers = arcCenters(count, inner, block, layout, 160, 20); + case 'arc': { + // arc 布局:通过 direction 控制 dome(穹顶)/ bowl(碗形)方向 + // - 'up'(默认):弧线在上方(穹顶),等同原 dome + // - 'down':弧线在下方(碗形),等同原 bowl + const isDown = layout.direction === 'down'; + const [s, e] = isDown ? [20, 160] : [200, 340]; + centers = arcCenters(count, inner, block, layout, s, e); break; + } case 'wing': { const direction = layout.direction === 'right' ? 'right' : 'left'; const [s, e] = direction === 'right' ? [110, 250] : [-70, 70]; @@ -207,21 +222,6 @@ const lineCenters = (count: number, x0: number, y0: number, x1: number, y1: numb }); }; -const alternatingHorizontalCenters = ( - count: number, - inner: { x: number; y: number; width: number; height: number }, - block: StorylineSize, - gap: number -) => { - const baseY = inner.y + inner.height / 2; - const offset = Math.min(Math.max(block.height * 0.65 + gap / 2, 0), Math.max((inner.height - block.height) / 2, 0)); - const points = lineCenters(count, inner.x + block.width / 2, baseY, inner.x + inner.width - block.width / 2, baseY); - return points.map((point, index) => ({ - x: point.x, - y: point.y + (index % 2 === 0 ? -offset : offset) - })); -}; - const alternatingVerticalCenters = ( count: number, inner: { x: number; y: number; width: number; height: number }, diff --git a/packages/vchart-extension/src/charts/storyline/layouts/bowl.ts b/packages/vchart-extension/src/charts/storyline/layouts/arc.ts similarity index 62% rename from packages/vchart-extension/src/charts/storyline/layouts/bowl.ts rename to packages/vchart-extension/src/charts/storyline/layouts/arc.ts index c4a34eb97e..73da492352 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/bowl.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/arc.ts @@ -15,78 +15,100 @@ import { withAlpha } from './common'; -// bowl 布局:dome 的上下镜像 -// - centerImage 贴顶(dome 是贴底) -// - 弧线在 centerImage 下方(dome 在上方) -// - block 沿弧线分布、image + title/content 位于弧线外侧(弧线下方) -// - title/content 位于 image 下方(dome 是上方) -// image 默认为圆形,BOWL_BLOCK_IMAGE_SIZE 即圆的直径 -const BOWL_BLOCK_IMAGE_SIZE = 140; -const BOWL_TEXT_GAP_FROM_IMAGE = 10; -const BOWL_TITLE_LINE_HEIGHT = 19; -const BOWL_CONTENT_LINE_HEIGHT = 17; -const BOWL_CONTENT_FONT_SIZE = 12; -// title + content 区域总高度(默认 300px,溢出由富文本 heightLimit + ellipsis 自动截断) -const BOWL_TEXT_BOX_HEIGHT = 300; -const BOWL_TITLE_TO_CONTENT_GAP = 4; +// arc 布局:弧形排列 + centerImage(穹顶 / 碗形二合一) +// - direction = 'up'(默认):穹顶 —— centerImage 贴底,弧线在 centerImage 上方 +// - direction = 'down':碗形 —— centerImage 贴顶,弧线在 centerImage 下方 +// image 默认为圆形,ARC_BLOCK_IMAGE_SIZE 即圆的直径 +const ARC_BLOCK_IMAGE_SIZE = 140; +const ARC_TEXT_GAP_FROM_IMAGE = 10; +const ARC_TITLE_LINE_HEIGHT = 19; +const ARC_CONTENT_LINE_HEIGHT = 17; +const ARC_CONTENT_FONT_SIZE = 12; +// title + content 区域总高度(默认 240px,溢出由富文本 heightLimit + ellipsis 自动截断) +const ARC_TEXT_BOX_HEIGHT = 240; +const ARC_TITLE_TO_CONTENT_GAP = 4; // 引导线与 title/content 之间的水平间距 -const BOWL_TEXT_LEFT_PADDING = 20; +const ARC_TEXT_LEFT_PADDING = 20; +// title/content 区域的最小宽度,确保文字有足够展示空间,不受 image 宽度限制 +const ARC_TEXT_BOX_MIN_WIDTH = 200; // centerImage 边长相对 inner 短边的比例(强制正方形,避免 cover 模式裁切图片) -const BOWL_CENTER_IMAGE_SIZE_RATIO = 0.4; -// 弧线最低点(视觉上的底点)距离 centerImage 底部的距离 -const BOWL_ARC_BOTTOM_GAP_FROM_CENTER_IMAGE = 300; +const ARC_CENTER_IMAGE_SIZE_RATIO = 0.4; +// 弧线最高/最低点距离 centerImage 顶部/底部的距离 +const ARC_GAP_FROM_CENTER_IMAGE = 200; + +const isDownArc = (spec: IStorylineSpec) => normalizeLayout(spec.layout).direction === 'down'; /** - * 计算 bowl 布局 centerImage 的 box:水平居中、垂直贴顶(位于 inner 区域顶部)。 + * 计算 arc 布局 centerImage 的 box:水平居中。 + * - up(dome):垂直贴底(位于 inner 区域底部) + * - down(bowl):垂直贴顶(位于 inner 区域顶部) */ -const getBowlCenterImageRect = (spec: IStorylineSpec, ctx: LayoutContext) => { +const getArcCenterImageRect = (spec: IStorylineSpec, ctx: LayoutContext) => { const { width, height, startX, startY } = getRegionGeometry(ctx); const padding = normalizePadding(spec.block?.padding); const innerWidth = Math.max(width - padding.left - padding.right, 1); const innerHeight = Math.max(height - padding.top - padding.bottom, 1); // 取 inner 短边作为基准,使 rect 始终为正方形(cover 模式下不会裁切方形图片) - const baseSize = Math.min(innerWidth, innerHeight) * BOWL_CENTER_IMAGE_SIZE_RATIO; + const baseSize = Math.min(innerWidth, innerHeight) * ARC_CENTER_IMAGE_SIZE_RATIO; const w = Math.max(spec.centerImage?.width ?? baseSize, 80); const h = Math.max(spec.centerImage?.height ?? baseSize, 80); const cx = startX + padding.left + innerWidth / 2; - // 紧贴顶部,仅保留 spec.block.padding.top 的留白 - const top = startY + padding.top; + const isDown = isDownArc(spec); + const top = isDown + ? // bowl:紧贴顶部 + startY + padding.top + : // dome:紧贴底部 + startY + padding.top + innerHeight - h; return { x: cx - w / 2, y: top, width: w, height: h }; }; /** - * 计算 bowl 弧线的几何参数: + * 计算 arc 弧线的几何参数: * - cx / rx / startAngle / endAngle 与 layout.ts 中 arcCenters 一致; - * - cy 与 ry 由两条对齐约束反推: - * 1) 弧线起/终点 y == centerImage 顶部 - * 2) 弧线最低点 y == centerImage 底部 + BOWL_ARC_BOTTOM_GAP_FROM_CENTER_IMAGE + * - cy 与 ry 由两条对齐约束反推,使弧线起/终点 y 与 centerImage 端面对齐, + * 弧线极值点(顶点 / 底点)距离 centerImage 远端 ARC_GAP_FROM_CENTER_IMAGE。 + * + * up(dome):startAngle = 200°、endAngle = 340°(弧线在 centerImage 上方) + * cy + ry * sin(startAngle) = centerImageBottom + * cy - ry = centerImageTop - GAP + * → ry = (centerImageHeight + GAP) / (1 + sin(startAngle)) + * cy = centerImageBottom - ry * sin(startAngle) * - * bowl 的 startAngle = 20°、endAngle = 160°(弧线在 centerImage 下方)。 - * 解方程: + * down(bowl):startAngle = 20°、endAngle = 160°(弧线在 centerImage 下方) * cy + ry * sin(startAngle) = centerImageTop * cy + ry = centerImageBottom + GAP * → ry = (GAP + centerImageHeight) / (1 - sin(startAngle)) * cy = centerImageTop - ry * sin(startAngle) */ -const getBowlArcGeometry = (spec: IStorylineSpec, ctx: LayoutContext) => { +const getArcGeometry = (spec: IStorylineSpec, ctx: LayoutContext) => { const { width, startX } = getRegionGeometry(ctx); const blockPadding = normalizePadding(spec.block?.padding); const innerWidth = Math.max(width - blockPadding.left - blockPadding.right, 1); const blockWidth = resolveBlockWidth(spec, width); const layoutOpt = normalizeLayout(spec.layout); - // bowl 默认弧线起止角与 layout.ts 中一致 - const startAngle = layoutOpt.startAngle ?? 20; - const endAngle = layoutOpt.endAngle ?? 160; + const isDown = layoutOpt.direction === 'down'; + // 默认弧线起止角与 layout.ts 中一致 + const startAngle = layoutOpt.startAngle ?? (isDown ? 20 : 200); + const endAngle = layoutOpt.endAngle ?? (isDown ? 160 : 340); const ratio = layoutOpt.radiusRatio ?? 0.88; const rx = Math.max((innerWidth - blockWidth) / 2, 1) * ratio; - const centerRect = getBowlCenterImageRect(spec, ctx); + const centerRect = getArcCenterImageRect(spec, ctx); const centerTop = centerRect.y; const centerBottom = centerRect.y + centerRect.height; const sinStart = Math.sin((startAngle / 180) * Math.PI); - // sinStart 接近 1 时 ry → ∞;这里限制下界以防 startAngle 配置异常 - const denom = Math.max(1 - sinStart, 0.05); - const ry = (centerRect.height + BOWL_ARC_BOTTOM_GAP_FROM_CENTER_IMAGE) / denom; - const cy = centerTop - ry * sinStart; + let cy: number; + let ry: number; + if (isDown) { + // bowl:sinStart 接近 1 时 ry → ∞;这里限制下界以防 startAngle 配置异常 + const denom = Math.max(1 - sinStart, 0.05); + ry = (centerRect.height + ARC_GAP_FROM_CENTER_IMAGE) / denom; + cy = centerTop - ry * sinStart; + } else { + // dome:sinStart 接近 -1 时 ry → ∞ + const denom = Math.max(1 + sinStart, 0.05); + ry = (centerRect.height + ARC_GAP_FROM_CENTER_IMAGE) / denom; + cy = centerBottom - ry * sinStart; + } return { cx: startX + blockPadding.left + innerWidth / 2, cy, @@ -100,12 +122,12 @@ const getBowlArcGeometry = (spec: IStorylineSpec, ctx: LayoutContext) => { }; /** - * 在 bowl 弧线上按 index 采样 block 中心,与 arc 完全同步。 + * 在 arc 弧线上按 index 采样 block 中心,与 arc 完全同步。 * 同时让 block 沿弧线径向向外偏移 imageHeight/2, - * 使 image 内边贴在弧线上,image + text 整体位于弧线外侧(下方)。 + * 使 image 内边贴在弧线上,image + text 整体位于弧线外侧。 */ -const getBowlBlockCenter = (spec: IStorylineSpec, ctx: LayoutContext, index: number): StorylinePoint => { - const arc = getBowlArcGeometry(spec, ctx); +const getArcBlockCenter = (spec: IStorylineSpec, ctx: LayoutContext, index: number): StorylinePoint => { + const arc = getArcGeometry(spec, ctx); const count = spec.data?.length ?? 0; if (count <= 0) { return { x: arc.cx, y: arc.cy }; @@ -120,28 +142,29 @@ const getBowlBlockCenter = (spec: IStorylineSpec, ctx: LayoutContext, index: num const nLen = Math.hypot(nxRaw, nyRaw) || 1; const nx = nxRaw / nLen; const ny = nyRaw / nLen; - const imageHeight = spec.image?.height ?? BOWL_BLOCK_IMAGE_SIZE; + const imageHeight = spec.image?.height ?? ARC_BLOCK_IMAGE_SIZE; const offset = imageHeight / 2; return { x: px + nx * offset, y: py + ny * offset }; }; /** - * 贯穿所有 block 的弧线 mark(path 通过沿椭圆采样实现) + * 贯穿所有 block 的弧线 mark(path 通过沿椭圆采样实现,与 arc block 的弧形布局完全重合) + * * 默认不展示,仅当用户在 spec.line.visible 显式置为 true 时才渲染。 */ -export const buildBowlArcMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { +export const buildArcMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { if (spec.line?.visible !== true) { return null; } const themeColor = getThemeColor(spec); return { type: 'group' as any, - name: 'storyline-bowl-arc', + name: 'storyline-arc', zIndex: LayoutZIndex.Mark, children: [ { type: 'path', - name: 'storyline-bowl-arc-path', + name: 'storyline-arc-path', interactive: false, style: { stroke: themeColor, @@ -150,7 +173,7 @@ export const buildBowlArcMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec fill: 'transparent', fillOpacity: 0, path: (_d: unknown, ctx: LayoutContext) => { - const arc = getBowlArcGeometry(spec, ctx); + const arc = getArcGeometry(spec, ctx); const span = arc.endAngle - arc.startAngle; const samples = 64; const segments: string[] = []; @@ -169,7 +192,7 @@ export const buildBowlArcMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec }; }; -export const buildBowlCenterImageMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { +export const buildArcCenterImageMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { const visible = spec.centerImage?.visible !== false; if (!visible) { return null; @@ -190,24 +213,24 @@ export const buildBowlCenterImageMark = (spec: IStorylineSpec): IExtensionGroupM }; return { type: 'group' as any, - name: 'storyline-bowl-center', + name: 'storyline-arc-center', zIndex: LayoutZIndex.Mark, children: [ { type: 'symbol', - name: 'storyline-bowl-center-symbol', + name: 'storyline-arc-center-symbol', interactive: false, style: { x: (_d: unknown, ctx: LayoutContext) => { - const r = getBowlCenterImageRect(spec, ctx); + const r = getArcCenterImageRect(spec, ctx); return r.x + r.width / 2; }, y: (_d: unknown, ctx: LayoutContext) => { - const r = getBowlCenterImageRect(spec, ctx); + const r = getArcCenterImageRect(spec, ctx); return r.y + r.height / 2; }, size: (_d: unknown, ctx: LayoutContext) => { - const r = getBowlCenterImageRect(spec, ctx); + const r = getArcCenterImageRect(spec, ctx); return Math.max(r.width, r.height) * 1.1; }, symbolType: 'circle', @@ -219,33 +242,33 @@ export const buildBowlCenterImageMark = (spec: IStorylineSpec): IExtensionGroupM hasImage ? ({ type: 'image', - name: 'storyline-bowl-center-image', + name: 'storyline-arc-center-image', interactive: false, ...spec.centerImage, style: { - x: (_d: unknown, ctx: LayoutContext) => getBowlCenterImageRect(spec, ctx).x, - y: (_d: unknown, ctx: LayoutContext) => getBowlCenterImageRect(spec, ctx).y, - width: (_d: unknown, ctx: LayoutContext) => getBowlCenterImageRect(spec, ctx).width, - height: (_d: unknown, ctx: LayoutContext) => getBowlCenterImageRect(spec, ctx).height, + x: (_d: unknown, ctx: LayoutContext) => getArcCenterImageRect(spec, ctx).x, + y: (_d: unknown, ctx: LayoutContext) => getArcCenterImageRect(spec, ctx).y, + width: (_d: unknown, ctx: LayoutContext) => getArcCenterImageRect(spec, ctx).width, + height: (_d: unknown, ctx: LayoutContext) => getArcCenterImageRect(spec, ctx).height, image: spec.centerImage?.image, - cornerRadius: 12, repeatX: 'no-repeat', repeatY: 'no-repeat', + imageMode: 'cover', imagePosition: 'center', // 默认锚点设为 image 中心,让 scaleX/scaleY 从中心缩放 anchor: (_d: unknown, ctx: LayoutContext) => { - const r = getBowlCenterImageRect(spec, ctx); + const r = getArcCenterImageRect(spec, ctx); return [r.x + r.width / 2, r.y + r.height / 2]; }, // 若用户在 style 里覆盖了 width/height,自动追加 dx/dy 让图片仍以 rect 中心为中心 dx: (_d: unknown, ctx: LayoutContext) => { - const r = getBowlCenterImageRect(spec, ctx); + const r = getArcCenterImageRect(spec, ctx); const userWidth = (spec.centerImage?.style as { width?: number } | undefined)?.width; const w = typeof userWidth === 'number' ? userWidth : r.width; return (r.width - w) / 2; }, dy: (_d: unknown, ctx: LayoutContext) => { - const r = getBowlCenterImageRect(spec, ctx); + const r = getArcCenterImageRect(spec, ctx); const userHeight = (spec.centerImage?.style as { height?: number } | undefined)?.height; const h = typeof userHeight === 'number' ? userHeight : r.height; return (r.height - h) / 2; @@ -258,31 +281,37 @@ export const buildBowlCenterImageMark = (spec: IStorylineSpec): IExtensionGroupM }; }; -const getBowlBlockMetrics = (spec: IStorylineSpec) => { - const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 14); +const getArcBlockMetrics = (spec: IStorylineSpec) => { + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 18); const titleLineHeight = Number( - (spec.title?.style as any)?.lineHeight ?? Math.max(BOWL_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.35)) + (spec.title?.style as any)?.lineHeight ?? Math.max(ARC_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.35)) ); - const contentFontSize = Number((spec.content?.style as any)?.fontSize ?? BOWL_CONTENT_FONT_SIZE); - const contentLineHeight = Number((spec.content?.style as any)?.lineHeight ?? BOWL_CONTENT_LINE_HEIGHT); - const titleToContentGap = BOWL_TITLE_TO_CONTENT_GAP; - const textHeight = BOWL_TEXT_BOX_HEIGHT; + const contentFontSize = Number((spec.content?.style as any)?.fontSize ?? ARC_CONTENT_FONT_SIZE); + const contentLineHeight = Number((spec.content?.style as any)?.lineHeight ?? ARC_CONTENT_LINE_HEIGHT); + const titleToContentGap = ARC_TITLE_TO_CONTENT_GAP; + // text 区域总高度固定为 ARC_TEXT_BOX_HEIGHT,content 占除 title 与间距外的全部高度 + const textHeight = ARC_TEXT_BOX_HEIGHT; const contentHeight = Math.max(textHeight - titleLineHeight - titleToContentGap, contentLineHeight); - const imageWidth = spec.image?.width ?? BOWL_BLOCK_IMAGE_SIZE; - const imageHeight = spec.image?.height ?? BOWL_BLOCK_IMAGE_SIZE; + const imageWidth = spec.image?.width ?? ARC_BLOCK_IMAGE_SIZE; + const imageHeight = spec.image?.height ?? ARC_BLOCK_IMAGE_SIZE; - // image 位于 block 中心,title/content 在 image 下方(与 dome 上下对称) + const isDown = isDownArc(spec); + + // image 位于 block 中心,title/content: + // - up(dome):在 image 上方; + // - down(bowl):在 image 下方 const imageBox = { x: -imageWidth / 2, y: -imageHeight / 2, width: imageWidth, height: imageHeight }; + const textBoxWidth = Math.max(imageWidth - ARC_TEXT_LEFT_PADDING, ARC_TEXT_BOX_MIN_WIDTH); const textBox = { - x: -imageWidth / 2 + BOWL_TEXT_LEFT_PADDING, - y: imageBox.y + imageHeight + BOWL_TEXT_GAP_FROM_IMAGE, - width: imageWidth - BOWL_TEXT_LEFT_PADDING, + x: -imageWidth / 2 + ARC_TEXT_LEFT_PADDING, + y: isDown ? imageBox.y + imageHeight + ARC_TEXT_GAP_FROM_IMAGE : imageBox.y - ARC_TEXT_GAP_FROM_IMAGE - textHeight, + width: textBoxWidth, height: textHeight }; const contentBox = { @@ -298,11 +327,12 @@ const getBowlBlockMetrics = (spec: IStorylineSpec) => { contentLineHeight, imageBox, textBox, - contentBox + contentBox, + isDown }; }; -export const buildBowlBlockMark = ( +export const buildArcBlockMark = ( spec: IStorylineSpec, block: IStorylineBlock, index: number @@ -310,7 +340,15 @@ export const buildBowlBlockMark = ( const hasImage = !!block.image; const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; const themeColor = getThemeColor(spec); - const metrics = getBowlBlockMetrics(spec); + const metrics = getArcBlockMetrics(spec); + + // 引导线 rect:贯穿 image 端面 → text 远端 + // - up(dome):从 textBox.y 到 imageBox.y(text 在 image 上方) + // - down(bowl):从 imageBox 底端 到 textBox 底端(text 在 image 下方) + const connectorY = metrics.isDown ? metrics.imageBox.y + metrics.imageBox.height : metrics.textBox.y; + const connectorHeight = metrics.isDown + ? Math.max(metrics.textBox.y + metrics.textBox.height - (metrics.imageBox.y + metrics.imageBox.height), 0) + : Math.max(metrics.imageBox.y - metrics.textBox.y, 0); return { type: 'group' as any, @@ -318,23 +356,20 @@ export const buildBowlBlockMark = ( name: `storyline-block-${index}`, zIndex: LayoutZIndex.Mark + 1, style: { - x: (_d: unknown, ctx: LayoutContext) => getBowlBlockCenter(spec, ctx, index).x, - y: (_d: unknown, ctx: LayoutContext) => getBowlBlockCenter(spec, ctx, index).y + x: (_d: unknown, ctx: LayoutContext) => getArcBlockCenter(spec, ctx, index).x, + y: (_d: unknown, ctx: LayoutContext) => getArcBlockCenter(spec, ctx, index).y }, children: [ - // title / content 左侧的垂直引导线(贯穿 image 底部 → text 底部,与文字保持 padding) + // title / content 左侧的垂直引导线 { type: 'rect', name: `storyline-block-connector-${index}`, interactive: false, style: { x: metrics.imageBox.x, - y: metrics.imageBox.y + metrics.imageBox.height, + y: connectorY, width: 2, - height: Math.max( - metrics.textBox.y + metrics.textBox.height - (metrics.imageBox.y + metrics.imageBox.height), - 0 - ), + height: connectorHeight, fill: themeColor, fillOpacity: 0.6 } @@ -351,14 +386,10 @@ export const buildBowlBlockMark = ( width: metrics.imageBox.width, height: metrics.imageBox.height, image: block.image, - // 圆形裁剪:cornerRadius = min(w,h) / 2 - cornerRadius: Math.min(metrics.imageBox.width, metrics.imageBox.height) / 2, repeatX: 'no-repeat', repeatY: 'no-repeat', imageMode: 'cover', imagePosition: 'center', - stroke: themeColor, - lineWidth: 2, ...spec.image?.style } } as ICustomMarkSpec<'image'>) @@ -392,6 +423,9 @@ export const buildBowlBlockMark = ( lineHeight: metrics.titleLineHeight, fontWeight: 'bold', fill: '#1f2430', + stroke: '#fff', + lineWidth: 5, + lineJoin: 'round', textAlign: 'left', textBaseline: 'top', ...spec.title?.style @@ -413,8 +447,8 @@ export const buildBowlBlockMark = ( maxLineWidth: metrics.contentBox.width, heightLimit: metrics.contentBox.height, text: buildRichContent(contentText, spec), - fontSize: BOWL_CONTENT_FONT_SIZE, - lineHeight: BOWL_CONTENT_LINE_HEIGHT, + fontSize: ARC_CONTENT_FONT_SIZE, + lineHeight: ARC_CONTENT_LINE_HEIGHT, textAlign: 'left', textBaseline: 'top', wordBreak: 'break-word', diff --git a/packages/vchart-extension/src/charts/storyline/layouts/clock.ts b/packages/vchart-extension/src/charts/storyline/layouts/clock.ts index f575e2e46b..62136dca2d 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/clock.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/clock.ts @@ -46,8 +46,8 @@ const CLOCK_TEXT_GAP_FROM_LEAD = 8; // 引线到文字的间距 px const CLOCK_ORBIT_DASH = [4, 4]; // ===== 文字 ===== -const CLOCK_TITLE_FONT_SIZE = 13; -const CLOCK_TITLE_LINE_HEIGHT = 18; +const CLOCK_TITLE_FONT_SIZE = 18; +const CLOCK_TITLE_LINE_HEIGHT = 24; const CLOCK_CONTENT_FONT_SIZE = 11; const CLOCK_CONTENT_LINE_HEIGHT = 15; @@ -141,8 +141,6 @@ export const buildClockCenterImageMark = (spec: IStorylineSpec): IExtensionGroup height: (_d: unknown, ctx: LayoutContext) => getClockGeometry(spec, ctx).R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO * 2, image: spec.centerImage?.image, - cornerRadius: (_d: unknown, ctx: LayoutContext) => - getClockGeometry(spec, ctx).R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO, repeatX: 'no-repeat', repeatY: 'no-repeat', imageMode: 'cover', @@ -321,13 +319,10 @@ export const buildClockBlockMark = ( width: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).diameter, height: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).diameter, image: block.image, - cornerRadius: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).diameter / 2, repeatX: 'no-repeat', repeatY: 'no-repeat', imageMode: 'cover', - imagePosition: 'center', - stroke: themeColor, - lineWidth: 2 + imagePosition: 'center' } } as ICustomMarkSpec<'image'>) : ({ @@ -361,6 +356,9 @@ export const buildClockBlockMark = ( lineHeight: CLOCK_TITLE_LINE_HEIGHT, fontWeight: 'bold', fill: themeColor, + stroke: '#fff', + lineWidth: 5, + lineJoin: 'round', textAlign: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).onLeft ? 'right' : 'left', textBaseline: 'top', diff --git a/packages/vchart-extension/src/charts/storyline/layouts/common.ts b/packages/vchart-extension/src/charts/storyline/layouts/common.ts index 64c160f868..d1949fdab3 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/common.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/common.ts @@ -37,9 +37,9 @@ export const DEFAULT_THEME_COLOR = '#e8543d'; export const isLandscape = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'landscape'; export const isPortrait = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'portrait'; export const isClock = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'clock'; -export const isDome = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'dome'; -export const isBowl = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'bowl'; +export const isArc = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'arc'; export const isWing = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'wing'; +export const isLadder = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'ladder'; export const getThemeColor = (spec: IStorylineSpec) => spec.themeColor ?? DEFAULT_THEME_COLOR; diff --git a/packages/vchart-extension/src/charts/storyline/layouts/default.ts b/packages/vchart-extension/src/charts/storyline/layouts/default.ts index be312746e5..74586796d7 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/default.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/default.ts @@ -65,7 +65,7 @@ const getDefaultBlockMetrics = (spec: IStorylineSpec, ctx: LayoutContext, index: const imageHeight = spec.image?.height ?? DEFAULT_IMAGE_HEIGHT; const imageGap = spec.image?.gap ?? DEFAULT_IMAGE_GAP; const hasImage = !!spec.data?.[index]?.image; - const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 14); + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 18); const titleLineHeight = Number((spec.title?.style as any)?.lineHeight ?? Math.round(titleFontSize * 1.35)); const titleHeight = spec.data?.[index]?.title ? titleLineHeight : 0; const blockWidth = block?.width ?? resolveBlockWidth(spec, 0); @@ -113,7 +113,7 @@ export const buildDefaultBlockMark = ( ): IExtensionGroupMarkSpec => { const hasImage = !!block.image; const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; - const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 14); + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 18); const titleLineHeight = Number((spec.title?.style as any)?.lineHeight ?? Math.round(titleFontSize * 1.35)); return { @@ -158,7 +158,6 @@ export const buildDefaultBlockMark = ( width: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).imageBox.width, height: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).imageBox.height, image: block.image, - cornerRadius: 6, ...spec.image?.style } } as ICustomMarkSpec<'image'>) @@ -179,6 +178,9 @@ export const buildDefaultBlockMark = ( lineHeight: titleLineHeight, fontWeight: 'bold', fill: '#1f2430', + stroke: '#fff', + lineWidth: 5, + lineJoin: 'round', textAlign: 'left', textBaseline: 'top', ...spec.title?.style diff --git a/packages/vchart-extension/src/charts/storyline/layouts/dome.ts b/packages/vchart-extension/src/charts/storyline/layouts/dome.ts deleted file mode 100644 index 0e8c695042..0000000000 --- a/packages/vchart-extension/src/charts/storyline/layouts/dome.ts +++ /dev/null @@ -1,445 +0,0 @@ -import type { IExtensionGroupMarkSpec } from '@visactor/vchart'; -import { LayoutZIndex } from '@visactor/vchart'; -import type { IStorylineBlock, IStorylineSpec } from '../interface'; -import { - type ICustomMarkSpec, - type LayoutContext, - type StorylinePoint, - buildRichContent, - getRegionGeometry, - getThemeColor, - normalizeLayout, - normalizePadding, - omitImageLayoutSpec, - resolveBlockWidth, - withAlpha -} from './common'; - -// dome 布局:弧形排列 + 底部 centerImage -// image 默认为圆形,DOME_BLOCK_IMAGE_SIZE 即圆的直径 -const DOME_BLOCK_IMAGE_SIZE = 140; -const DOME_TEXT_GAP_FROM_IMAGE = 10; -const DOME_TITLE_LINE_HEIGHT = 19; -const DOME_CONTENT_LINE_HEIGHT = 17; -const DOME_CONTENT_FONT_SIZE = 12; -// title + content 区域总高度(默认 400px,溢出由富文本 heightLimit + ellipsis 自动截断) -const DOME_TEXT_BOX_HEIGHT = 300; -const DOME_TITLE_TO_CONTENT_GAP = 4; -// 引导线与 title/content 之间的水平间距 -const DOME_TEXT_LEFT_PADDING = 20; -// centerImage 边长相对 inner 短边的比例(强制正方形,避免 cover 模式裁切图片) -const DOME_CENTER_IMAGE_SIZE_RATIO = 0.4; -// 弧线最高点(视觉上的顶点)距离 centerImage 顶部的距离 -const DOME_ARC_TOP_GAP_FROM_CENTER_IMAGE = 300; - -/** - * 计算 dome 布局 centerImage 的 box:水平居中、垂直贴底(位于 inner 区域底部)。 - */ -const getDomeCenterImageRect = (spec: IStorylineSpec, ctx: LayoutContext) => { - const { width, height, startX, startY } = getRegionGeometry(ctx); - const padding = normalizePadding(spec.block?.padding); - const innerWidth = Math.max(width - padding.left - padding.right, 1); - const innerHeight = Math.max(height - padding.top - padding.bottom, 1); - // 取 inner 短边作为基准,使 rect 始终为正方形(cover 模式下不会裁切方形图片) - const baseSize = Math.min(innerWidth, innerHeight) * DOME_CENTER_IMAGE_SIZE_RATIO; - const w = Math.max(spec.centerImage?.width ?? baseSize, 80); - const h = Math.max(spec.centerImage?.height ?? baseSize, 80); - const cx = startX + padding.left + innerWidth / 2; - // 紧贴底部,仅保留 spec.block.padding.bottom 的留白 - const top = startY + padding.top + innerHeight - h; - return { x: cx - w / 2, y: top, width: w, height: h }; -}; - -/** - * 计算 dome 弧线的几何参数: - * - cx / rx / startAngle / endAngle 与 layout.ts 中 arcCenters 一致; - * - cy 与 ry 由两条对齐约束反推: - * 1) 弧线起/终点 y == centerImage 底部 - * 2) 弧线最高点 y == centerImage 顶部 - DOME_ARC_TOP_GAP_FROM_CENTER_IMAGE - * - * 解方程: - * cy + ry * sin(startAngle) = centerImageBottom - * cy - ry = centerImageTop - GAP - * → ry = (centerImageHeight + GAP) / (1 + sin(startAngle)) - * cy = centerImageBottom - ry * sin(startAngle) - * - * 仅当 sin(startAngle) ∈ [-1, 0) 时(即 startAngle 在 (180°, 360°) 区间,碗形), - * 该方程组有合理正解。 - */ -const getDomeArcGeometry = (spec: IStorylineSpec, ctx: LayoutContext) => { - const { width, startX } = getRegionGeometry(ctx); - const blockPadding = normalizePadding(spec.block?.padding); - const innerWidth = Math.max(width - blockPadding.left - blockPadding.right, 1); - const blockWidth = resolveBlockWidth(spec, width); - const layoutOpt = normalizeLayout(spec.layout); - const startAngle = layoutOpt.startAngle ?? 200; - const endAngle = layoutOpt.endAngle ?? 340; - const ratio = layoutOpt.radiusRatio ?? 0.88; - const rx = Math.max((innerWidth - blockWidth) / 2, 1) * ratio; - const centerRect = getDomeCenterImageRect(spec, ctx); - const centerTop = centerRect.y; - const centerBottom = centerRect.y + centerRect.height; - const sinStart = Math.sin((startAngle / 180) * Math.PI); - // sinStart 接近 -1 时 ry → ∞;这里限制下界以防 startAngle 配置异常 - const denom = Math.max(1 + sinStart, 0.05); - const ry = (centerRect.height + DOME_ARC_TOP_GAP_FROM_CENTER_IMAGE) / denom; - const cy = centerBottom - ry * sinStart; - return { - cx: startX + blockPadding.left + innerWidth / 2, - cy, - rx, - ry, - startAngle, - endAngle, - // 调试/对齐用:上下两个对齐参考点 - centerTop, - centerBottom - }; -}; - -/** - * 在 do me 新弧线上按 index 采样 block 中心,与 arc 完全同步。 - * 同时让 block 沿弧线径向向外偏移 imageHeight/2, - * 使 image 内边贴在弧线上,image + text 整体位于弧线外侧。 - */ -const getDomeBlockCenter = (spec: IStorylineSpec, ctx: LayoutContext, index: number): StorylinePoint => { - const arc = getDomeArcGeometry(spec, ctx); - const count = spec.data?.length ?? 0; - if (count <= 0) { - return { x: arc.cx, y: arc.cy }; - } - const t = count === 1 ? 0.5 : index / (count - 1); - const angle = ((arc.startAngle + (arc.endAngle - arc.startAngle) * t) / 180) * Math.PI; - const px = arc.cx + Math.cos(angle) * arc.rx; - const py = arc.cy + Math.sin(angle) * arc.ry; - // 椭圆在 (px,py) 处的外法向量 ∝ (cos(angle)/rx, sin(angle)/ry) - const nxRaw = Math.cos(angle) / arc.rx; - const nyRaw = Math.sin(angle) / arc.ry; - const nLen = Math.hypot(nxRaw, nyRaw) || 1; - const nx = nxRaw / nLen; - const ny = nyRaw / nLen; - const imageHeight = spec.image?.height ?? DOME_BLOCK_IMAGE_SIZE; - const offset = imageHeight / 2; - return { x: px + nx * offset, y: py + ny * offset }; -}; - -/** - * 贯穿所有 block 的半圆弧线 mark(path 通过沿椭圆采样实现, - * 与 dome block 的弧形布局完全重合) - * - * 默认不展示,仅当用户在 spec.line.visible 显式置为 true 时才渲染。 - */ -export const buildDomeArcMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { - if (spec.line?.visible !== true) { - return null; - } - const themeColor = getThemeColor(spec); - return { - type: 'group' as any, - name: 'storyline-dome-arc', - zIndex: LayoutZIndex.Mark, - children: [ - { - type: 'path', - name: 'storyline-dome-arc-path', - interactive: false, - style: { - stroke: themeColor, - lineWidth: 2, - lineCap: 'round', - fill: 'transparent', - fillOpacity: 0, - path: (_d: unknown, ctx: LayoutContext) => { - const arc = getDomeArcGeometry(spec, ctx); - const span = arc.endAngle - arc.startAngle; - const samples = 64; - const segments: string[] = []; - for (let i = 0; i <= samples; i++) { - const t = i / samples; - const angle = ((arc.startAngle + span * t) / 180) * Math.PI; - const x = arc.cx + Math.cos(angle) * arc.rx; - const y = arc.cy + Math.sin(angle) * arc.ry; - segments.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`); - } - return segments.join(' '); - } - } - } as ICustomMarkSpec<'path'> - ] - }; -}; - -export const buildDomeCenterImageMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { - const visible = spec.centerImage?.visible !== false; - if (!visible) { - return null; - } - const themeColor = getThemeColor(spec); - const hasImage = !!spec.centerImage?.image; - // 主题色生成的线性渐变(顶部偏淡 → 底部主题色),作为 centerImage 位置的 symbol 填充 - const symbolGradient = { - gradient: 'linear', - x0: 0.5, - y0: 0, - x1: 0.5, - y1: 1, - stops: [ - { offset: 0, color: withAlpha(themeColor, 0.15) }, - { offset: 1, color: themeColor } - ] - }; - return { - type: 'group' as any, - name: 'storyline-dome-center', - zIndex: LayoutZIndex.Mark, - children: [ - // 默认 symbol:位于 centerImage 的位置,外径略大于 centerImage(填充渐变作为视觉底盘) - { - type: 'symbol', - name: 'storyline-dome-center-symbol', - interactive: false, - style: { - x: (_d: unknown, ctx: LayoutContext) => { - const r = getDomeCenterImageRect(spec, ctx); - return r.x + r.width / 2; - }, - y: (_d: unknown, ctx: LayoutContext) => { - const r = getDomeCenterImageRect(spec, ctx); - return r.y + r.height / 2; - }, - size: (_d: unknown, ctx: LayoutContext) => { - const r = getDomeCenterImageRect(spec, ctx); - // symbol 直径略大于 centerImage 较短边,形成"圆形底盘" - return Math.max(r.width, r.height) * 1.1; - }, - symbolType: 'circle', - fill: symbolGradient, - stroke: themeColor, - lineWidth: 2 - } - } as ICustomMarkSpec<'symbol'>, - { - type: 'rect', - name: 'storyline-dome-center-rect', - interactive: false, - style: { - x: (_d: unknown, ctx: LayoutContext) => getDomeCenterImageRect(spec, ctx).x, - y: (_d: unknown, ctx: LayoutContext) => getDomeCenterImageRect(spec, ctx).y, - width: (_d: unknown, ctx: LayoutContext) => getDomeCenterImageRect(spec, ctx).width, - height: (_d: unknown, ctx: LayoutContext) => getDomeCenterImageRect(spec, ctx).height, - cornerRadius: 12, - fill: '#ffffff', - stroke: themeColor, - lineWidth: 2 - } - } as ICustomMarkSpec<'rect'>, - hasImage - ? ({ - type: 'image', - name: 'storyline-dome-center-image', - interactive: false, - ...spec.centerImage, - style: { - x: (_d: unknown, ctx: LayoutContext) => getDomeCenterImageRect(spec, ctx).x, - y: (_d: unknown, ctx: LayoutContext) => getDomeCenterImageRect(spec, ctx).y, - width: (_d: unknown, ctx: LayoutContext) => getDomeCenterImageRect(spec, ctx).width, - height: (_d: unknown, ctx: LayoutContext) => getDomeCenterImageRect(spec, ctx).height, - image: spec.centerImage?.image, - cornerRadius: 12, - repeatX: 'no-repeat', - repeatY: 'no-repeat', - imageMode: 'cover', - imagePosition: 'center', - // 默认锚点设为 image 中心,让 scaleX/scaleY 从中心缩放 - anchor: (_d: unknown, ctx: LayoutContext) => { - const r = getDomeCenterImageRect(spec, ctx); - return [r.x + r.width / 2, r.y + r.height / 2]; - }, - // 若用户在 style 里覆盖了 width/height,自动追加 dx/dy 让图片仍以 rect 中心为中心 - dx: (_d: unknown, ctx: LayoutContext) => { - const r = getDomeCenterImageRect(spec, ctx); - const userWidth = (spec.centerImage?.style as { width?: number } | undefined)?.width; - const w = typeof userWidth === 'number' ? userWidth : r.width; - return (r.width - w) / 2; - }, - dy: (_d: unknown, ctx: LayoutContext) => { - const r = getDomeCenterImageRect(spec, ctx); - const userHeight = (spec.centerImage?.style as { height?: number } | undefined)?.height; - const h = typeof userHeight === 'number' ? userHeight : r.height; - return (r.height - h) / 2; - }, - ...spec.centerImage?.style - } - } as ICustomMarkSpec<'image'>) - : null - ].filter(Boolean) as ICustomMarkSpec[] - }; -}; - -const getDomeBlockMetrics = (spec: IStorylineSpec) => { - const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 14); - const titleLineHeight = Number( - (spec.title?.style as any)?.lineHeight ?? Math.max(DOME_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.35)) - ); - const contentFontSize = Number((spec.content?.style as any)?.fontSize ?? DOME_CONTENT_FONT_SIZE); - const contentLineHeight = Number((spec.content?.style as any)?.lineHeight ?? DOME_CONTENT_LINE_HEIGHT); - const titleToContentGap = DOME_TITLE_TO_CONTENT_GAP; - // text 区域总高度固定为 DOME_TEXT_BOX_HEIGHT,content 占除 title 与间距外的全部高度 - const textHeight = DOME_TEXT_BOX_HEIGHT; - const contentHeight = Math.max(textHeight - titleLineHeight - titleToContentGap, contentLineHeight); - - const imageWidth = spec.image?.width ?? DOME_BLOCK_IMAGE_SIZE; - const imageHeight = spec.image?.height ?? DOME_BLOCK_IMAGE_SIZE; - - // image 位于 block 中心下半部分,title/content 在 image 上方 - const imageBox = { - x: -imageWidth / 2, - y: -imageHeight / 2, - width: imageWidth, - height: imageHeight - }; - const textBox = { - x: -imageWidth / 2 + DOME_TEXT_LEFT_PADDING, - y: imageBox.y - DOME_TEXT_GAP_FROM_IMAGE - textHeight, - width: imageWidth - DOME_TEXT_LEFT_PADDING, - height: textHeight - }; - const contentBox = { - x: textBox.x, - y: textBox.y + titleLineHeight + titleToContentGap, - width: textBox.width, - height: contentHeight - }; - return { - titleFontSize, - titleLineHeight, - contentFontSize, - contentLineHeight, - imageBox, - textBox, - contentBox - }; -}; - -export const buildDomeBlockMark = ( - spec: IStorylineSpec, - block: IStorylineBlock, - index: number -): IExtensionGroupMarkSpec => { - const hasImage = !!block.image; - const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; - const themeColor = getThemeColor(spec); - const metrics = getDomeBlockMetrics(spec); - - return { - type: 'group' as any, - id: `storyline-block-${block.id ?? index}`, - name: `storyline-block-${index}`, - zIndex: LayoutZIndex.Mark + 1, - style: { - x: (_d: unknown, ctx: LayoutContext) => getDomeBlockCenter(spec, ctx, index).x, - y: (_d: unknown, ctx: LayoutContext) => getDomeBlockCenter(spec, ctx, index).y - }, - children: [ - // title / content 左侧的垂直引导线(贯穿 text + image 顶部,与文字保持 padding) - { - type: 'rect', - name: `storyline-block-connector-${index}`, - interactive: false, - style: { - x: metrics.imageBox.x, - y: metrics.textBox.y, - width: 2, - height: Math.max(metrics.imageBox.y - metrics.textBox.y, 0), - fill: themeColor, - fillOpacity: 0.6 - } - } as ICustomMarkSpec<'rect'>, - hasImage - ? ({ - type: 'image', - name: `storyline-block-image-${index}`, - interactive: false, - ...omitImageLayoutSpec(spec.image), - style: { - x: metrics.imageBox.x, - y: metrics.imageBox.y, - width: metrics.imageBox.width, - height: metrics.imageBox.height, - image: block.image, - // 圆形裁剪:cornerRadius = min(w,h) / 2 - cornerRadius: Math.min(metrics.imageBox.width, metrics.imageBox.height) / 2, - repeatX: 'no-repeat', - repeatY: 'no-repeat', - imageMode: 'cover', - imagePosition: 'center', - stroke: themeColor, - lineWidth: 2, - ...spec.image?.style - } - } as ICustomMarkSpec<'image'>) - : ({ - type: 'rect', - name: `storyline-block-image-bg-${index}`, - interactive: false, - style: { - x: metrics.imageBox.x, - y: metrics.imageBox.y, - width: metrics.imageBox.width, - height: metrics.imageBox.height, - cornerRadius: Math.min(metrics.imageBox.width, metrics.imageBox.height) / 2, - fill: '#ffffff', - stroke: themeColor, - lineWidth: 2 - } - } as ICustomMarkSpec<'rect'>), - block.title - ? ({ - type: 'text', - name: `storyline-block-title-${index}`, - interactive: false, - ...spec.title, - style: { - x: metrics.textBox.x, - y: metrics.textBox.y, - text: block.title, - maxLineWidth: metrics.textBox.width, - fontSize: metrics.titleFontSize, - lineHeight: metrics.titleLineHeight, - fontWeight: 'bold', - fill: '#1f2430', - textAlign: 'left', - textBaseline: 'top', - ...spec.title?.style - } - } as ICustomMarkSpec<'text'>) - : null, - contentText.length - ? ({ - type: 'text', - name: `storyline-block-content-${index}`, - interactive: false, - ...spec.content, - textType: 'rich', - style: { - x: metrics.contentBox.x, - y: metrics.contentBox.y, - width: metrics.contentBox.width, - height: metrics.contentBox.height, - maxLineWidth: metrics.contentBox.width, - heightLimit: metrics.contentBox.height, - text: buildRichContent(contentText, spec), - fontSize: DOME_CONTENT_FONT_SIZE, - lineHeight: DOME_CONTENT_LINE_HEIGHT, - textAlign: 'left', - textBaseline: 'top', - wordBreak: 'break-word', - ellipsis: '...', - fill: '#596173', - ...spec.content?.style - } - } as ICustomMarkSpec<'text'>) - : null - ].filter(Boolean) as ICustomMarkSpec[] - }; -}; diff --git a/packages/vchart-extension/src/charts/storyline/layouts/ladder.ts b/packages/vchart-extension/src/charts/storyline/layouts/ladder.ts new file mode 100644 index 0000000000..5022dc73e8 --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/layouts/ladder.ts @@ -0,0 +1,367 @@ +import { LayoutZIndex, type IExtensionGroupMarkSpec } from '@visactor/vchart'; +import type { IStorylineBlock, IStorylineSpec } from '../interface'; +import { + type ICustomMarkSpec, + type LayoutContext, + DEFAULT_BLOCK_HEIGHT, + DEFAULT_IMAGE_GAP, + buildRichContent, + getImageBox, + getLayout, + getRegionGeometry, + getTextBox, + getThemeColor, + normalizeLayout, + normalizePadding, + omitImageLayoutSpec, + resolveBlockWidth, + withAlpha +} from './common'; + +/** + * ladder 布局:参考 Bauhaus 信息图。 + * + * 视觉结构(direction='up'): + * ┌─────────────────────────────────────────────────┐ + * │ [block 2] │ + * │ ╲╲╲ │ + * │ ╲╲╲ headline 大字 ╲╲╲ │ + * │ ╲╲╲ │ + * │ [block 1] │ + * └─────────────────────────────────────────────────┘ + * + * - direction='up'(默认):对角线左下 → 右上 + * - direction='down':对角线左上 → 右下 + * - headline 大字沿对角线方向旋转,叠加在对角线上 + * - 每个 block 在对角线上取一个 anchor 点,沿对角线法向左偏 / 右偏交替放置 + * layout.ts 中 'ladder' 分支已经给出 block 中心点位置,本文件只关心 + * "对角线本身" 与 "headline 文本" 这两个装饰图元,以及 block 的左右镜像排版。 + */ + +// headline 字号占可用高度的比例,自适应于不同画布 +const LADDER_HEADLINE_FONT_RATIO = 0.42; +const LADDER_HEADLINE_FONT_MIN = 80; +const LADDER_HEADLINE_FONT_MAX = 240; +const LADDER_DIAGONAL_LINE_WIDTH = 2; +const LADDER_DIAGONAL_DASH = [12, 8]; + +// ladder 中 block 的默认视觉参数(比通用默认值更大,符合 Bauhaus 信息图风格) +const LADDER_BLOCK_IMAGE_SIZE = 96; +const LADDER_TITLE_FONT_SIZE = 28; +const LADDER_TITLE_LINE_HEIGHT = 36; +const LADDER_CONTENT_FONT_SIZE = 16; +const LADDER_CONTENT_LINE_HEIGHT = 24; + +const isDownLadder = (spec: IStorylineSpec) => normalizeLayout(spec.layout).direction === 'down'; + +/** + * 计算对角线两个端点(与 layout.ts 中 ladder 的 anchors 起止点保持一致)。 + * - direction='up'(默认):左下 → 右上 + * - direction='down':左上 → 右下 + */ +const getLadderDiagonalGeometry = (spec: IStorylineSpec, ctx: LayoutContext) => { + const { width, height, startX, startY } = getRegionGeometry(ctx); + const padding = normalizePadding(spec.block?.padding); + const innerX = startX + padding.left; + const innerY = startY + padding.top; + const innerW = Math.max(width - padding.left - padding.right, 1); + const innerH = Math.max(height - padding.top - padding.bottom, 1); + const isDown = isDownLadder(spec); + const x0 = innerX; + const x1 = innerX + innerW; + const y0 = isDown ? innerY : innerY + innerH; + const y1 = isDown ? innerY + innerH : innerY; + const cx = (x0 + x1) / 2; + const cy = (y0 + y1) / 2; + // 对角线方向角度(度,画布坐标系:顺时针为正)。 + // up 时 dy<0 → 负角度(向上倾);down 时 dy>0 → 正角度(向下倾)。 + const dx = x1 - x0; + const dy = y1 - y0; + const angleRad = (Math.atan2(dy, dx) / Math.PI) * 180; + const fontSize = Math.max( + LADDER_HEADLINE_FONT_MIN, + Math.min(LADDER_HEADLINE_FONT_MAX, Math.round(innerH * LADDER_HEADLINE_FONT_RATIO)) + ); + return { x0, y0, x1, y1, cx, cy, angleRad, fontSize }; +}; + +const getLadderHeadlineText = (spec: IStorylineSpec) => { + const layoutOpt = normalizeLayout(spec.layout); + if (typeof layoutOpt.headline === 'string' && layoutOpt.headline.length > 0) { + return layoutOpt.headline; + } + // 回退:使用 spec.title 文本,再退化为占位 + const title = (spec.title?.style as { text?: string } | undefined)?.text; + if (typeof title === 'string' && title.length > 0) { + return title; + } + return 'storyline'; +}; + +/** + * 对角线 mark:贯穿 inner 矩形。 + */ +export const buildLadderDiagonalMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec => { + const themeColor = getThemeColor(spec); + return { + type: 'group' as any, + name: 'storyline-ladder-diagonal', + // 对角线在最底层 + zIndex: LayoutZIndex.Mark - 1, + children: [ + { + type: 'path', + name: 'storyline-ladder-diagonal-line', + interactive: false, + style: { + path: (_d: unknown, ctx: LayoutContext) => { + const g = getLadderDiagonalGeometry(spec, ctx); + return `M ${g.x0} ${g.y0} L ${g.x1} ${g.y1}`; + }, + stroke: withAlpha(themeColor, 0.85), + lineWidth: LADDER_DIAGONAL_LINE_WIDTH, + lineDash: LADDER_DIAGONAL_DASH, + fill: 'transparent' + } + } as ICustomMarkSpec<'path'> + ] + }; +}; + +/** + * 倾斜的大字 headline mark,方向与对角线完全一致。 + * 整个 group 围绕对角线中点 (cx, cy) 旋转 angle,文本本身做水平/垂直居中。 + */ +export const buildLadderHeadlineMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + const text = getLadderHeadlineText(spec); + if (!text) { + return null; + } + const themeColor = getThemeColor(spec); + return { + type: 'group' as any, + name: 'storyline-ladder-headline', + // headline 在对角线之上、block 之下 + zIndex: LayoutZIndex.Mark, + style: { + x: (_d: unknown, ctx: LayoutContext) => getLadderDiagonalGeometry(spec, ctx).cx, + y: (_d: unknown, ctx: LayoutContext) => getLadderDiagonalGeometry(spec, ctx).cy, + angle: (_d: unknown, ctx: LayoutContext) => getLadderDiagonalGeometry(spec, ctx).angleRad + }, + children: [ + { + type: 'text', + name: 'storyline-ladder-headline-text', + interactive: false, + style: { + // 相对 group 局部坐标,(0, 0) 即旋转中心 + x: 0, + y: 0, + text, + fontSize: (_d: unknown, ctx: LayoutContext) => getLadderDiagonalGeometry(spec, ctx).fontSize, + fontWeight: 900, + fontFamily: 'Impact, "Arial Black", sans-serif', + fill: withAlpha(themeColor, 0.92), + textAlign: 'center', + textBaseline: 'middle' + } + } as ICustomMarkSpec<'text'> + ] + }; +}; + +// ===== block mark ===== + +/** + * ladder 中"对角线左侧 block"的判定: + * layout.ts 中的法向 (nx, ny) = (-dy/len, dx/len)。 + * - direction='up':dy<0 → ny<0,sign=+1 → ny*sign<0 → 上方;可推得 偶数 index 在右上侧、奇数在左下侧。 + * "对角线左侧" = 奇数 index。 + * - direction='down':dy>0 → ny>0,sign=+1 → ny*sign>0 → 下方;偶数 index 在右下侧、奇数在左上侧。 + * "对角线左侧" = 奇数 index。 + * 因此两种 direction 下"对角线左侧"的判定一致。 + */ +const isOnLeft = (index: number) => index % 2 === 1; + +const getLadderBlockMetrics = (spec: IStorylineSpec, ctx: LayoutContext, index: number) => { + const block = getLayout(spec, ctx).blocks[index]; + const padding = normalizePadding(spec.block?.padding ?? 12); + // 左侧 block:image 放右;右侧 block:image 放左 + const imagePosition = isOnLeft(index) ? ('right' as const) : ('left' as const); + const imageWidth = spec.image?.width ?? LADDER_BLOCK_IMAGE_SIZE; + const imageHeight = spec.image?.height ?? LADDER_BLOCK_IMAGE_SIZE; + const imageGap = spec.image?.gap ?? DEFAULT_IMAGE_GAP; + const hasImage = !!spec.data?.[index]?.image; + const titleFontSize = Number( + (spec.title?.style as { fontSize?: number } | undefined)?.fontSize ?? LADDER_TITLE_FONT_SIZE + ); + const titleLineHeight = Number( + (spec.title?.style as { lineHeight?: number } | undefined)?.lineHeight ?? + Math.round(titleFontSize * (LADDER_TITLE_LINE_HEIGHT / LADDER_TITLE_FONT_SIZE)) + ); + const titleHeight = spec.data?.[index]?.title ? titleLineHeight : 0; + const blockWidth = block?.width ?? resolveBlockWidth(spec, 0); + const blockHeight = block?.height ?? spec.block?.height ?? DEFAULT_BLOCK_HEIGHT; + const imageBox = getImageBox( + imagePosition, + blockWidth, + blockHeight, + padding, + imageWidth, + imageHeight, + imageGap, + hasImage + ); + const textBox = getTextBox( + imagePosition, + blockWidth, + blockHeight, + padding, + imageWidth, + imageHeight, + imageGap, + hasImage + ); + const contentGap = spec.data?.[index]?.title ? 8 : 0; + return { + block: { width: blockWidth, height: blockHeight }, + imageBox, + textBox, + contentBox: { + y: textBox.y + titleHeight + contentGap, + height: Math.max(0, textBox.height - titleHeight - contentGap) + } + }; +}; + +/** + * ladder 的 block mark: + * - 对角线左侧 block:image 在右,title / content textAlign='right' + * - 对角线右侧 block:image 在左,title / content textAlign='left' + */ +export const buildLadderBlockMark = ( + spec: IStorylineSpec, + block: IStorylineBlock, + index: number +): IExtensionGroupMarkSpec => { + const hasImage = !!block.image; + const onLeft = isOnLeft(index); + const align = onLeft ? 'right' : 'left'; + const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; + const titleFontSize = Number( + (spec.title?.style as { fontSize?: number } | undefined)?.fontSize ?? LADDER_TITLE_FONT_SIZE + ); + const titleLineHeight = Number( + (spec.title?.style as { lineHeight?: number } | undefined)?.lineHeight ?? + Math.round(titleFontSize * (LADDER_TITLE_LINE_HEIGHT / LADDER_TITLE_FONT_SIZE)) + ); + const showBackground = spec.block?.showBackground === true; + + // textAlign='right' 时 x 锚点取 textBox 右端,否则取左端 + const getTitleX = (ctx: LayoutContext) => { + const m = getLadderBlockMetrics(spec, ctx, index); + return align === 'right' ? m.textBox.x + m.textBox.width : m.textBox.x; + }; + + return { + type: 'group' as any, + id: `storyline-block-${block.id ?? index}`, + name: `storyline-block-${index}`, + zIndex: LayoutZIndex.Mark + 1, + style: { + x: (_d: unknown, ctx: LayoutContext) => getLayout(spec, ctx).blocks[index]?.x ?? 0, + y: (_d: unknown, ctx: LayoutContext) => getLayout(spec, ctx).blocks[index]?.y ?? 0, + width: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).block.width, + height: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).block.height + }, + children: [ + showBackground + ? ({ + type: 'rect', + name: `storyline-block-bg-${index}`, + interactive: false, + style: { + x: 0, + y: 0, + width: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).block.width, + height: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).block.height, + cornerRadius: 8, + fill: '#ffffff', + stroke: '#d7dce5', + lineWidth: 1, + shadowBlur: 6, + shadowColor: 'rgba(0, 0, 0, 0.08)', + ...spec.block?.style + } + } as ICustomMarkSpec<'rect'>) + : null, + hasImage + ? ({ + type: 'image', + name: `storyline-block-image-${index}`, + interactive: false, + ...omitImageLayoutSpec(spec.image), + style: { + x: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).imageBox.x, + y: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).imageBox.y, + width: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).imageBox.width, + height: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).imageBox.height, + image: block.image, + ...spec.image?.style + } + } as ICustomMarkSpec<'image'>) + : null, + block.title + ? ({ + type: 'text', + name: `storyline-block-title-${index}`, + interactive: false, + ...spec.title, + style: { + x: (_d: unknown, ctx: LayoutContext) => getTitleX(ctx), + y: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).textBox.y, + text: block.title, + maxLineWidth: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).textBox.width, + fontSize: titleFontSize, + lineHeight: titleLineHeight, + fontWeight: 'bold', + fill: '#1f2430', + stroke: '#fff', + lineWidth: 5, + lineJoin: 'round', + textBaseline: 'top', + ...spec.title?.style, + // 由于 ladder 强制按对角线左右镜像,textAlign 不允许被外层 spec.title.style 覆盖 + textAlign: align + } + } as ICustomMarkSpec<'text'>) + : null, + contentText.length + ? ({ + type: 'text', + name: `storyline-block-content-${index}`, + interactive: false, + ...spec.content, + textType: 'rich', + style: { + x: (_d: unknown, ctx: LayoutContext) => getTitleX(ctx), + y: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).contentBox.y, + width: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).textBox.width, + text: buildRichContent(contentText, spec), + maxLineWidth: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).textBox.width, + fontSize: LADDER_CONTENT_FONT_SIZE, + lineHeight: LADDER_CONTENT_LINE_HEIGHT, + heightLimit: (_d: unknown, ctx: LayoutContext) => + getLadderBlockMetrics(spec, ctx, index).contentBox.height, + textBaseline: 'top', + wordBreak: 'break-word', + ellipsis: '...', + fill: '#596173', + ...spec.content?.style, + textAlign: align + } + } as ICustomMarkSpec<'text'>) + : null + ].filter(Boolean) as ICustomMarkSpec<'rect' | 'image' | 'text'>[] + }; +}; diff --git a/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts b/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts index 0d59dbb3ba..bdcc0e3973 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts @@ -107,7 +107,7 @@ export const buildLandscapeConnectingCurve = (spec: IStorylineSpec): IExtensionG */ const getLandscapeMetrics = (spec: IStorylineSpec, blockWidth: number, blockHeight: number, index: number) => { const padding = normalizePadding(spec.block?.padding ?? 12); - const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 14); + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 18); const titleLineHeight = Number( (spec.title?.style as any)?.lineHeight ?? Math.max(LANDSCAPE_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.35)) ); @@ -198,7 +198,7 @@ export const buildLandscapeBlockMark = ( ): IExtensionGroupMarkSpec => { const hasImage = !!block.image; const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; - const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 14); + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 18); const titleLineHeight = Number((spec.title?.style as any)?.lineHeight ?? Math.round(titleFontSize * 1.35)); const getMetrics = (ctx: LayoutContext) => { @@ -265,7 +265,6 @@ export const buildLandscapeBlockMark = ( width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.width, height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.height, image: block.image, - cornerRadius: 8, repeatX: 'no-repeat', repeatY: 'no-repeat', imageMode: 'cover', @@ -312,6 +311,9 @@ export const buildLandscapeBlockMark = ( lineHeight: titleLineHeight, fontWeight: 'bold', fill: '#1f2430', + stroke: '#fff', + lineWidth: 5, + lineJoin: 'round', textAlign: 'left', textBaseline: 'top', ...spec.title?.style diff --git a/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts b/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts index 8e2474d3b7..c84352944b 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts @@ -19,9 +19,9 @@ const PORTRAIT_AXIS_PADDING = 50; // 中轴上下两端的留白 const PORTRAIT_IMAGE_WIDTH = 180; const PORTRAIT_IMAGE_HEIGHT = 110; const PORTRAIT_IMAGE_GAP_FROM_AXIS = 24; // image 与中轴之间的水平间距 -const PORTRAIT_SHADOW_OFFSET_X = 36; -const PORTRAIT_SHADOW_OFFSET_Y = 20; -const PORTRAIT_SHADOW_SCALE = 1.12; +const PORTRAIT_SHADOW_OFFSET_X = 24; // subImage 相对主 image 的水平错位量 +const PORTRAIT_SHADOW_OFFSET_Y = 16; // subImage 相对主 image 的垂直错位量 +const PORTRAIT_SHADOW_SCALE = 1; // subImage 与主 image 同尺寸,仅做错位偏移 const PORTRAIT_TEXT_GAP_FROM_IMAGE = 8; const PORTRAIT_CONTENT_LINES = 3; const PORTRAIT_TITLE_LINE_HEIGHT = 19; @@ -89,7 +89,7 @@ export const buildPortraitAxisMark = (spec: IStorylineSpec): IExtensionGroupMark }; const getPortraitMetrics = (spec: IStorylineSpec, blockWidth: number, _blockHeight: number, index: number) => { - const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 14); + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 18); const titleLineHeight = Number( (spec.title?.style as any)?.lineHeight ?? Math.max(PORTRAIT_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.35)) ); @@ -154,11 +154,14 @@ export const buildPortraitBlockMark = ( index: number ): IExtensionGroupMarkSpec => { const hasImage = !!block.image; + const hasSubImage = !!block.subImage; const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; - const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 14); + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 18); const titleLineHeight = Number( (spec.title?.style as any)?.lineHeight ?? Math.max(PORTRAIT_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.35)) ); + // image 背后的装饰图元(错位 shadow image + mask)默认不展示 + const showBackground = spec.image?.showBackground === true; const getMetrics = (ctx: LayoutContext) => { const lb = getLayout(spec, ctx).blocks[index]; @@ -185,7 +188,7 @@ export const buildPortraitBlockMark = ( } }, children: [ - hasImage + hasSubImage && showBackground ? ({ type: 'image', name: `storyline-block-shadow-image-${index}`, @@ -195,8 +198,7 @@ export const buildPortraitBlockMark = ( y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).shadowBox.y, width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).shadowBox.width, height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).shadowBox.height, - image: block.image, - cornerRadius: 8, + image: block.subImage, repeatX: 'no-repeat', repeatY: 'no-repeat', imageMode: 'cover', @@ -204,33 +206,6 @@ export const buildPortraitBlockMark = ( } } as ICustomMarkSpec<'image'>) : null, - hasImage - ? ({ - type: 'rect', - name: `storyline-block-shadow-mask-${index}`, - interactive: false, - style: { - x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).shadowBox.x, - y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).shadowBox.y, - width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).shadowBox.width, - height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).shadowBox.height, - cornerRadius: 8, - stroke: false, - lineWidth: 0, - fill: { - gradient: 'linear', - x0: 0, - y0: 0, - x1: 0, - y1: 1, - stops: [ - { offset: 0, color: withAlpha(themeColor, 0.2) }, - { offset: 1, color: withAlpha(themeColor, 1) } - ] - } - } - } as ICustomMarkSpec<'rect'>) - : null, { type: 'rect', name: `storyline-block-image-bg-${index}`, @@ -259,7 +234,6 @@ export const buildPortraitBlockMark = ( width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.width, height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.height, image: block.image, - cornerRadius: 8, repeatX: 'no-repeat', repeatY: 'no-repeat', imageMode: 'cover', @@ -283,6 +257,9 @@ export const buildPortraitBlockMark = ( lineHeight: titleLineHeight, fontWeight: 'bold', fill: '#1f2430', + stroke: '#fff', + lineWidth: 5, + lineJoin: 'round', textAlign: 'left', textBaseline: 'top', ...spec.title?.style diff --git a/packages/vchart-extension/src/charts/storyline/layouts/wing.ts b/packages/vchart-extension/src/charts/storyline/layouts/wing.ts index ef693ebc45..2589d56c61 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/wing.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/wing.ts @@ -23,8 +23,8 @@ import { // - 左右交替(弧线左侧 / 右侧)让节点错落 const WING_BLOCK_IMAGE_SIZE = 96; const WING_TEXT_GAP_FROM_IMAGE = 14; -const WING_TITLE_LINE_HEIGHT = 26; -const WING_TITLE_FONT_SIZE = 20; +const WING_TITLE_LINE_HEIGHT = 30; +const WING_TITLE_FONT_SIZE = 22; const WING_CONTENT_LINE_HEIGHT = 17; const WING_CONTENT_FONT_SIZE = 12; // title + content 区域宽度 @@ -216,6 +216,8 @@ export const buildWingBlockMark = ( const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; const themeColor = getThemeColor(spec); const metrics = getWingBlockMetrics(spec, index); + // image 背后的装饰图元(halo)默认不展示 + const showBackground = spec.image?.showBackground === true; return { type: 'group' as any, @@ -227,20 +229,22 @@ export const buildWingBlockMark = ( y: (_d: unknown, ctx: LayoutContext) => getWingBlockCenter(spec, ctx, index).y }, children: [ - { - type: 'symbol', - name: `storyline-block-image-halo-${index}`, - interactive: false, - style: { - x: 0, - y: 0, - size: Math.max(metrics.imageBox.width, metrics.imageBox.height) + 12, - symbolType: 'circle', - fill: withAlpha(themeColor, 0.18), - stroke: themeColor, - lineWidth: 1.5 - } - } as ICustomMarkSpec<'symbol'>, + showBackground + ? ({ + type: 'symbol', + name: `storyline-block-image-halo-${index}`, + interactive: false, + style: { + x: 0, + y: 0, + size: Math.max(metrics.imageBox.width, metrics.imageBox.height) + 12, + symbolType: 'circle', + fill: withAlpha(themeColor, 0.18), + stroke: themeColor, + lineWidth: 1.5 + } + } as ICustomMarkSpec<'symbol'>) + : null, hasImage ? ({ type: 'image', @@ -253,13 +257,10 @@ export const buildWingBlockMark = ( width: metrics.imageBox.width, height: metrics.imageBox.height, image: block.image, - cornerRadius: Math.min(metrics.imageBox.width, metrics.imageBox.height) / 2, repeatX: 'no-repeat', repeatY: 'no-repeat', imageMode: 'cover', imagePosition: 'center', - stroke: '#ffffff', - lineWidth: 3, ...spec.image?.style } } as ICustomMarkSpec<'image'>) @@ -294,6 +295,9 @@ export const buildWingBlockMark = ( lineHeight: metrics.titleLineHeight, fontWeight: 'bold', fill: themeColor, + stroke: '#fff', + lineWidth: 5, + lineJoin: 'round', textAlign: metrics.onLeft ? 'right' : 'left', textBaseline: 'top', ...spec.title?.style diff --git a/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts b/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts index 31177f4552..0b1fc9126f 100644 --- a/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts +++ b/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts @@ -1,13 +1,24 @@ import { CommonChartSpecTransformer, type IExtensionGroupMarkSpec } from '@visactor/vchart'; import type { IStorylineBlock, IStorylineSpec } from './interface'; -import { isBowl, isClock, isDome, isLandscape, isPortrait, isWing } from './layouts/common'; +import { + isArc, + isClock, + isLadder, + isLandscape, + isPortrait, + isWing, + normalizeLayout, + resolveBlockWidth, + DEFAULT_BLOCK_WIDTH, + DEFAULT_IMAGE_GAP +} from './layouts/common'; import { buildClockArcMark, buildClockBlockMark, buildClockCenterImageMark } from './layouts/clock'; import { buildDefaultBlockMark, buildDefaultLineMark } from './layouts/default'; import { buildLandscapeBlockMark, buildLandscapeConnectingCurve } from './layouts/landscape'; import { buildPortraitAxisMark, buildPortraitBlockMark } from './layouts/portrait'; -import { buildDomeArcMark, buildDomeBlockMark, buildDomeCenterImageMark } from './layouts/dome'; -import { buildBowlArcMark, buildBowlBlockMark, buildBowlCenterImageMark } from './layouts/bowl'; +import { buildArcBlockMark, buildArcCenterImageMark, buildArcMark } from './layouts/arc'; import { buildWingArcMark, buildWingBlockMark } from './layouts/wing'; +import { buildLadderBlockMark, buildLadderDiagonalMark, buildLadderHeadlineMark } from './layouts/ladder'; export class StorylineChartSpecTransformer extends CommonChartSpecTransformer { transformSpec(spec: any): void { @@ -30,36 +41,86 @@ export class StorylineChartSpecTransformer extends CommonChartSpecTransformer { const LARGE = 100; const SMALL = 20; - const bowl = isBowl(spec as IStorylineSpec); - const defaultTop = bowl ? LARGE : SMALL; - const defaultBottom = bowl ? SMALL : LARGE; + // 给 textBox(240px)+ 一定呼吸空间,避免内容超出画布 + const TEXT_RESERVE = 280; + // portrait 最后一个 block 下方的 textBox 大约 60-80px,加 image 半高、间距,预留 160px + const PORTRAIT_BOTTOM_RESERVE = 160; + const arc = isArc(spec as IStorylineSpec); + const arcDown = arc && normalizeLayout((spec as IStorylineSpec).layout).direction === 'down'; + const arcUp = arc && !arcDown; + const portrait = isPortrait(spec as IStorylineSpec); + const ladder = isLadder(spec as IStorylineSpec); + // ladder: + // - 左右 padding ≈ block content 宽度 × 2(保证两端 block 沿对角线水平有呼吸) + // - 上下 padding ≈ block 高度 × 3(保证两端 block 沿对角线垂直留出充足画布留白) + // 由于 transformSpec 阶段还无法获取真实 viewWidth,这里直接用 spec 中的估值 + const ladderHorizontalPadding = (() => { + if (!ladder) { + return 0; + } + const blockWidth = (spec as IStorylineSpec).block?.minWidth ?? resolveBlockWidth(spec as IStorylineSpec, 0); + const imageWidth = (spec as IStorylineSpec).image?.width ?? 96; // UP_LADDER_BLOCK_IMAGE_SIZE + const imageGap = (spec as IStorylineSpec).image?.gap ?? DEFAULT_IMAGE_GAP; + const innerPadding = 12 * 2; // up-ladder 默认 block padding 12,左右共 24 + const contentWidth = Math.max(blockWidth - imageWidth - imageGap - innerPadding, DEFAULT_BLOCK_WIDTH * 0.5); + return Math.round(contentWidth * 2); + })(); + const ladderVerticalPadding = (() => { + if (!ladder) { + return 0; + } + const blockHeight = (spec as IStorylineSpec).block?.height ?? 132; + return Math.round(blockHeight * 3); + })(); + // arc up(dome): 底部贴 centerImage(LARGE),顶部留给 textBox(TEXT_RESERVE) + // arc down(bowl): 顶部贴 centerImage(LARGE),底部留给 textBox(TEXT_RESERVE) + // portrait: 底部留给最后一个 block 的 textBox(PORTRAIT_BOTTOM_RESERVE) + // ladder: 四周均为 content 宽度 + // 其它:保持原默认 [SMALL, SMALL, LARGE, SMALL] + const defaultTop = ladder ? ladderVerticalPadding : arcDown ? LARGE : arcUp ? TEXT_RESERVE : SMALL; + const defaultBottom = ladder + ? ladderVerticalPadding + : arcDown + ? TEXT_RESERVE + : portrait + ? PORTRAIT_BOTTOM_RESERVE + : LARGE; + const defaultLeft = ladder ? ladderHorizontalPadding : SMALL; + const defaultRight = ladder ? ladderHorizontalPadding : SMALL; const p = spec.padding; if (p === undefined || p === null) { - spec.padding = [defaultTop, SMALL, defaultBottom, SMALL]; + spec.padding = [defaultTop, defaultRight, defaultBottom, defaultLeft]; return; } if (typeof p === 'number') { - spec.padding = bowl ? [Math.max(p, LARGE), p, p, p] : [p, p, Math.max(p, LARGE), p]; + spec.padding = [ + Math.max(p, defaultTop), + Math.max(p, defaultRight), + Math.max(p, defaultBottom), + Math.max(p, defaultLeft) + ]; return; } if (Array.isArray(p)) { - const [t, r = SMALL, b, l = SMALL] = p; + const [t, r = defaultRight, b, l = defaultLeft] = p; spec.padding = [t ?? defaultTop, r, b ?? defaultBottom, l]; return; } if (typeof p === 'object') { spec.padding = { top: p.top ?? defaultTop, - right: p.right ?? SMALL, + right: p.right ?? defaultRight, bottom: p.bottom ?? defaultBottom, - left: p.left ?? SMALL + left: p.left ?? defaultLeft }; } }; @@ -75,17 +136,12 @@ const buildStorylineMarks = (spec: IStorylineSpec) => { if (isPortrait(spec)) { return [lineMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; } - // dome:先绘制 centerImage(最底层视觉锚点),再绘制贯穿 block 的弧线,最后绘制 block; - // dome 不绘制 block 之间默认的连接线 - if (isDome(spec)) { - const centerImageMark = buildDomeCenterImageMark(spec); - const arcMark = buildDomeArcMark(spec); - return [centerImageMark, arcMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; - } - // bowl:dome 的上下镜像 —— centerImage 贴顶,弧线 + block 在下方 - if (isBowl(spec)) { - const centerImageMark = buildBowlCenterImageMark(spec); - const arcMark = buildBowlArcMark(spec); + // arc:先绘制 centerImage(最底层视觉锚点),再绘制贯穿 block 的弧线,最后绘制 block; + // arc 不绘制 block 之间默认的连接线。direction = 'up' 时 centerImage 贴底(穹顶), + // direction = 'down' 时 centerImage 贴顶(碗形) + if (isArc(spec)) { + const centerImageMark = buildArcCenterImageMark(spec); + const arcMark = buildArcMark(spec); return [centerImageMark, arcMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; } // clock:辐射式信息盘 —— 圆环骨架 + 径向分隔线 → centerImage(盘心)→ blocks(楔形 + 外圈文字) @@ -100,6 +156,12 @@ const buildStorylineMarks = (spec: IStorylineSpec) => { const arcMark = buildWingArcMark(spec); return [arcMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; } + // ladder:参考 Bauhaus 信息图 —— 对角线 + 沿对角线倾斜的 headline 大字 + 两侧错落 block + if (isLadder(spec)) { + const diagonalMark = buildLadderDiagonalMark(spec); + const headlineMark = buildLadderHeadlineMark(spec); + return [diagonalMark, headlineMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; + } return [lineMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; }; @@ -123,11 +185,8 @@ const buildBlockMark = (spec: IStorylineSpec, block: IStorylineBlock, index: num if (isPortrait(spec)) { return buildPortraitBlockMark(spec, block, index); } - if (isDome(spec)) { - return buildDomeBlockMark(spec, block, index); - } - if (isBowl(spec)) { - return buildBowlBlockMark(spec, block, index); + if (isArc(spec)) { + return buildArcBlockMark(spec, block, index); } if (isClock(spec)) { return buildClockBlockMark(spec, block, index); @@ -135,6 +194,9 @@ const buildBlockMark = (spec: IStorylineSpec, block: IStorylineBlock, index: num if (isWing(spec)) { return buildWingBlockMark(spec, block, index); } + if (isLadder(spec)) { + return buildLadderBlockMark(spec, block, index); + } return buildDefaultBlockMark(spec, block, index); }; From 77ed70e1f19211cd8dfdd34c644edd313024f48a Mon Sep 17 00:00:00 2001 From: skie1997 Date: Tue, 16 Jun 2026 17:52:54 +0800 Subject: [PATCH 4/8] fix: ladder angele problem --- .../runtime/browser/test-page/storyline.ts | 141 ++++++++++++++---- .../src/charts/storyline/layouts/common.ts | 4 +- .../src/charts/storyline/layouts/portrait.ts | 10 +- .../charts/storyline/storyline-transformer.ts | 8 +- 4 files changed, 130 insertions(+), 33 deletions(-) diff --git a/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts b/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts index 7f780b751f..6e784a1ca6 100644 --- a/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts +++ b/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts @@ -181,7 +181,7 @@ const createClockSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ padding: [20, 20, 50, 20], layout: 'clock', themeColor: '#C8102E', - background: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + // background: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', data: [ { id: 'uruguay-1930', @@ -190,14 +190,14 @@ const createClockSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ '1930年7月,国际足联首届世界杯在乌拉圭蒙得维的亚开幕,仅有十三支球队参赛。' + '东道主乌拉圭借助世纪球场坐镇,决赛中以4比2逆转近邻阿根廷,捧起了雷米特金杯。' + '乌拉圭队队长纳萨齐高举奖杯的画面,从此奠定了世界杯作为全球足球最高荣誉的象征意义。', - image: 'assets/node-uruguay-1930.png' + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' }, { id: 'brazil-1958', title: '贝利天才登场', content: '1958年瑞典世界杯成为足球新王登基的舞台。年仅十七岁的贝利首次代表巴西出战,在四分之一决赛对威尔士贡献关键进球,半决赛对法国上演帽子戏法,决赛对东道主瑞典再度梅开二度,帮助巴西首夺世界杯冠军。', - image: 'assets/node-brazil-1958.png' + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1958.png' }, { id: 'mexico-1986', @@ -206,7 +206,7 @@ const createClockSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ '1986年墨西哥世界杯由马拉多纳一人定义。四分之一决赛对英格兰,' + '他先用左手将球送入网窝制造『上帝之手』,紧接着又从中圈带球连过五人攻入世纪进球,' + '让阿根廷在马岛战争阴影下挣得舆论高地。半决赛对比利时再献两粒精彩入球,最终阿根廷3比2夺冠。', - image: 'assets/node-mexico-1986.png' + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1986.png' }, { id: 'france-1998', @@ -215,14 +215,14 @@ const createClockSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ '1998年法国世界杯由东道主自己谱写童话。决赛在圣丹尼新落成的法兰西大球场进行,' + '齐达内两次起跳头槌破门,将卫冕冠军巴西打懵,最终法国3比0大胜首夺世界杯。' + '比赛终场哨响时,香榭丽舍大街涌入百万球迷,蓝白红的海洋与齐达内剪影一同映在凯旋门上。', - image: 'assets/node-france-1998.png' + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1998.png' }, { id: 'germany-2014', title: '战车碾过马拉卡纳', content: '2014年巴西世界杯德国队成为最大赢家。半决赛德国在贝洛奥里藏特7比1血洗东道主巴西,决赛在传奇的马拉卡纳球场进行,加时赛第113分钟,戈策胸停凌空抽射打进绝杀,德国时隔24年再夺世界杯。', - image: 'assets/node-germany-2014.png' + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-2014.png' }, { id: 'qatar-2022', @@ -279,12 +279,13 @@ const createClockSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ } }, centerImage: { - // image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', - width: 360, - height: 360, + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + width: 300, + height: 300, style: { - width: 360, - height: 360 + width: 300, + height: 300, + cornerRadius: 150 } } }); @@ -300,7 +301,7 @@ const createDefaultSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ widthRatio: 0.28, minWidth: 220, maxWidth: 320, - height: 132, + height: 192, padding: 12, gap: 40, style: { fill: '#ffffff', stroke: '#d8deea', lineWidth: 1, cornerRadius: 8 } @@ -355,23 +356,111 @@ const createWingSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ // 通过 layout.direction('up' | 'down')控制对角线方向 const createLadderSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ type: 'storyline', - data: buildData(layout), - layout: { type: 'ladder', direction: 'up', headline: 'bauhaus' }, - themeColor, + width: 1600, + height: 500, + padding: 20, + layout: { type: 'ladder', direction: 'up', headline: 'ladder' }, + themeColor: '#C8102E', + background: 'transparent', + data: [ + { + id: 'uruguay-1930', + title: '首届世界杯诞生', + content: + '1930年7月,国际足联首届世界杯在乌拉圭蒙得维的亚开幕,仅有十三支球队参赛。' + + '东道主乌拉圭坐镇世纪球场,决赛中以4比2逆转近邻阿根廷,捧起了雷米特金杯。', + image: 'assets/node-uruguay-1930.png' + }, + { + id: 'brazil-1958', + title: '贝利天才登场', + content: + '1958年瑞典世界杯成为足球新王登基的舞台。年仅十七岁的贝利首次代表巴西出战,' + + '在四分之一决赛对威尔士贡献关键进球,半决赛对法国上演帽子戏法,' + + '决赛对东道主瑞典再度梅开二度,帮助巴西首夺世界杯冠军。', + image: 'assets/node-brazil-1958.png' + }, + { + id: 'mexico-1986', + title: '马拉多纳神迹', + content: + '1986年墨西哥世界杯由马拉多纳一人定义。四分之一决赛对英格兰,' + + '他先用左手将球送入网窝制造『上帝之手』,紧接着又从中圈带球连过五人攻入世纪进球,' + + '让阿根廷在马岛战争阴影下挣得舆论高地。', + image: 'assets/node-mexico-1986.png' + }, + { + id: 'france-1998', + title: '齐祖之夜法兰西', + content: + '1998年法国世界杯由东道主自己谱写童话。决赛在圣丹尼新落成的法兰西大球场进行,' + + '齐达内两次起跳头槌破门,将卫冕冠军巴西打懵,最终法国3比0大胜首夺世界杯。', + image: 'assets/node-france-1998.png' + }, + { + id: 'germany-2014', + title: '战车碾过马拉卡纳', + content: + '2014年巴西世界杯德国队成为最大赢家。半决赛德国在贝洛奥里藏特7比1血洗东道主巴西,' + + '决赛在传奇的马拉卡纳球场进行,加时赛第113分钟,戈策胸停凌空抽射打进绝杀,' + + '德国时隔24年再夺世界杯。', + image: 'assets/node-germany-2014.png' + }, + { + id: 'qatar-2022', + title: '梅西终圆封王梦', + content: + '2022年卡塔尔世界杯成为首届在中东和北半球冬季举行的世界杯。决赛在卢赛尔体育场进行,' + + '阿根廷与法国上演被誉为史上最经典的对决。梅西梅开二度,' + + '姆巴佩则上演世界杯决赛六十五年来首个帽子戏法,常规及加时赛战成3比3。' + + '点球大战中阿根廷4比2取胜。', + image: 'assets/node-qatar-2022.png' + } + ], block: { - widthRatio: 0.26, - minWidth: 200, - maxWidth: 280, - height: 132, + widthRatio: 0.28, + minWidth: 220, + maxWidth: 320, + height: 192, padding: 12, - gap: 24, - style: { fill: '#ffffff', stroke: '#d8deea', lineWidth: 1, cornerRadius: 8 } + gap: 40, + style: { + fill: 'rgba(255,255,255,0.92)', + stroke: 'rgba(200,16,46,0.18)', + lineWidth: 1, + cornerRadius: 8 + } }, - image: { position: 'left', gap: 12 }, - title: commonTitle, - content: commonContent, - // headline 已是视觉轴,关闭 block 间默认连线 - line: { visible: false } + image: { + position: 'left', + gap: 12 + }, + title: { + style: { + fontSize: 14, + fontWeight: 700, + fill: '#1f2533', + fontFamily: '"Times New Roman", Times, "Songti SC", "SimSun", serif' + } + }, + content: { + style: { + fontSize: 12, + lineHeight: 17, + fill: '#596579', + fontFamily: '"Songti SC", "STSong", "SimSun", serif' + } + }, + line: { + type: 'line', + showArrow: true, + style: { + lineWidth: 1.5, + lineCap: 'round', + lineJoin: 'round', + lineDash: [6, 5] + } + } }); const specBuilderByLayout: Partial IStorylineSpec>> = { diff --git a/packages/vchart-extension/src/charts/storyline/layouts/common.ts b/packages/vchart-extension/src/charts/storyline/layouts/common.ts index d1949fdab3..092ce03208 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/common.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/common.ts @@ -24,7 +24,7 @@ export type LayoutContext = { // ===== 通用默认值 ===== export const DEFAULT_BLOCK_WIDTH = 180; -export const DEFAULT_BLOCK_HEIGHT = 112; +export const DEFAULT_BLOCK_HEIGHT = 400; export const DEFAULT_BLOCK_WIDTH_RATIO = 0.24; export const DEFAULT_BLOCK_GAP = 36; export const DEFAULT_IMAGE_WIDTH = 48; @@ -131,7 +131,7 @@ export const getLayout = (spec: IStorylineSpec, ctx: LayoutContext): StorylineLa if (count > 0) { const padding = normalizePadding(spec.block?.padding); const innerHeight = Math.max(height - padding.top - padding.bottom, 1); - blockHeight = Math.max(160, Math.floor(innerHeight / count)); + blockHeight = Math.max(DEFAULT_BLOCK_HEIGHT, Math.floor(innerHeight / count)); } } const result = computeStorylineLayout(spec.data ?? [], { diff --git a/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts b/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts index c84352944b..d1f0abbb9f 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts @@ -88,19 +88,23 @@ export const buildPortraitAxisMark = (spec: IStorylineSpec): IExtensionGroupMark }; }; -const getPortraitMetrics = (spec: IStorylineSpec, blockWidth: number, _blockHeight: number, index: number) => { +const getPortraitMetrics = (spec: IStorylineSpec, blockWidth: number, blockHeight: number, index: number) => { const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 18); const titleLineHeight = Number( (spec.title?.style as any)?.lineHeight ?? Math.max(PORTRAIT_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.35)) ); const contentFontSize = Number((spec.content?.style as any)?.fontSize ?? PORTRAIT_CONTENT_FONT_SIZE); const contentLineHeight = Number((spec.content?.style as any)?.lineHeight ?? PORTRAIT_CONTENT_LINE_HEIGHT); - const contentHeight = PORTRAIT_CONTENT_LINES * contentLineHeight; const titleToContentGap = PORTRAIT_TITLE_TO_CONTENT_GAP; - const textHeight = titleLineHeight + titleToContentGap + contentHeight; const imageWidth = spec.image?.width ?? PORTRAIT_IMAGE_WIDTH; const imageHeight = spec.image?.height ?? PORTRAIT_IMAGE_HEIGHT; + const minContentHeight = PORTRAIT_CONTENT_LINES * contentLineHeight; + const contentHeight = Math.max( + minContentHeight, + blockHeight - imageHeight / 2 - PORTRAIT_TEXT_GAP_FROM_IMAGE - titleLineHeight - titleToContentGap + ); + const textHeight = titleLineHeight + titleToContentGap + contentHeight; const onLeft = index % 2 === 0; diff --git a/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts b/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts index 0b1fc9126f..e8c61fc8a1 100644 --- a/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts +++ b/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts @@ -10,6 +10,7 @@ import { normalizeLayout, resolveBlockWidth, DEFAULT_BLOCK_WIDTH, + DEFAULT_BLOCK_HEIGHT, DEFAULT_IMAGE_GAP } from './layouts/common'; import { buildClockArcMark, buildClockBlockMark, buildClockCenterImageMark } from './layouts/clock'; @@ -78,8 +79,11 @@ const applyDefaultPadding = (spec: any) => { if (!ladder) { return 0; } - const blockHeight = (spec as IStorylineSpec).block?.height ?? 132; - return Math.round(blockHeight * 3); + const blockHeight = (spec as IStorylineSpec).block?.height ?? DEFAULT_BLOCK_HEIGHT; + const chartHeight = (spec as IStorylineSpec).height; + const heightCap = + typeof chartHeight === 'number' && chartHeight > 0 ? Math.max(SMALL, Math.round(chartHeight * 0.18)) : Infinity; + return Math.round(Math.min(blockHeight * 3, heightCap)); })(); // arc up(dome): 底部贴 centerImage(LARGE),顶部留给 textBox(TEXT_RESERVE) // arc down(bowl): 顶部贴 centerImage(LARGE),底部留给 textBox(TEXT_RESERVE) From cb867f7b76a73ef1621a99516d573b4d4abfb0f0 Mon Sep 17 00:00:00 2001 From: skie1997 Date: Thu, 18 Jun 2026 01:56:20 +0800 Subject: [PATCH 5/8] feat: enhance default layout params --- .../runtime/browser/test-page/storyline.ts | 257 ++++-------------- .../src/charts/storyline/interface.ts | 21 +- .../src/charts/storyline/layout.ts | 14 +- .../src/charts/storyline/layouts/arc.ts | 218 ++++++++++----- .../src/charts/storyline/layouts/clock.ts | 37 ++- .../src/charts/storyline/layouts/common.ts | 37 ++- .../src/charts/storyline/layouts/default.ts | 46 ++-- .../src/charts/storyline/layouts/ladder.ts | 16 +- .../src/charts/storyline/layouts/landscape.ts | 31 ++- .../src/charts/storyline/layouts/portrait.ts | 154 ++++++++--- .../src/charts/storyline/layouts/wing.ts | 211 ++++++++++---- .../charts/storyline/storyline-transformer.ts | 121 ++++++--- 12 files changed, 694 insertions(+), 469 deletions(-) diff --git a/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts b/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts index 6e784a1ca6..74dce6e8ca 100644 --- a/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts +++ b/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts @@ -12,7 +12,8 @@ const baseData = [ title: 'Discover', content: 'Collect the first signal and frame the story. Capture every relevant detail from the source material ' + - 'so the audience can reconstruct the same context the author had when starting the analysis.', + 'so the audience can reconstruct the same context the author had when starting the analysis.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', subImage: SUB_IMAGE_URL }, @@ -21,7 +22,8 @@ const baseData = [ title: 'Group', content: 'Arrange related facts into a compact block, removing duplicates and aligning each fragment ' + - 'to the central theme so readers can scan supporting evidence at a glance without losing context.', + 'to the central theme so readers can scan supporting evidence at a glance without losing context.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', subImage: SUB_IMAGE_URL }, @@ -30,7 +32,8 @@ const baseData = [ title: 'Connect', content: 'Draw the reading path between blocks. Use repeating motifs, parallel sentence structures ' + - 'and visual cues to establish a continuous flow that walks the reader from premise to conclusion.', + 'and visual cues to establish a continuous flow that walks the reader from premise to conclusion.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', subImage: SUB_IMAGE_URL }, @@ -39,7 +42,8 @@ const baseData = [ title: 'Emphasize', content: 'Use image, title, and copy as one visual unit. Highlight the most important facts with typography ' + - 'weight, color contrast or motion so the eye instinctively returns to them while scanning.', + 'weight, color contrast or motion so the eye instinctively returns to them while scanning.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', subImage: SUB_IMAGE_URL }, @@ -48,7 +52,8 @@ const baseData = [ title: 'Resolve', content: 'End with a clear takeaway. Summarize the lesson, point out the next decision the audience ' + - 'should make and remove any ambiguity so the story closes with a satisfying, actionable conclusion.', + 'should make and remove any ambiguity so the story closes with a satisfying, actionable conclusion.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', subImage: SUB_IMAGE_URL } @@ -66,11 +71,14 @@ const buildData = (layout: StorylineLayoutType) => { const count = randomCountByLayout[layout]; return Array.from({ length: count }, (_, index) => { const seed = baseData[index % baseData.length]; + // portrait 布局:附加 marker 时间节点(2012、2013…)以便沿中轴纵向展示 + const marker = layout === 'portrait' ? String(2012 + index) : undefined; return { ...seed, id: `${layout}-${index}-${seed.id}`, title: `${seed.title} ${index + 1}`, - content: [`${seed.content}`, `Layout ${layout} / Block ${index + 1} of ${count}.`] + content: [`${seed.content}`, `Layout ${layout} / Block ${index + 1} of ${count}.`], + ...(marker ? { marker } : {}) }; }); }; @@ -78,17 +86,17 @@ const buildData = (layout: StorylineLayoutType) => { // 通用 title / content 样式(所有布局共享) const commonTitle: IStorylineSpec['title'] = { style: { - fontSize: 14, - fill: '#1f2533', - fontWeight: 700 + // fontSize: 14, + // fill: '#1f2533', + // fontWeight: 700 } }; const commonContent: IStorylineSpec['content'] = { style: { - fontSize: 12, - lineHeight: 17, - fill: '#596579' + // fontSize: 12, + // lineHeight: 17, + // fill: '#596579' } }; @@ -105,69 +113,41 @@ const commonLine: IStorylineSpec['line'] = { const themeColor = 'rgb(228,154,56)'; +const WIDTH = 1920; +const HEIGHT = 1080; + // landscape:图片错落 + 贯穿曲线,block 含上下两个卡片,垂直空间更大 const createLandscapeSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ type: 'storyline', padding: 20, + width: WIDTH, + height: HEIGHT, data: buildData(layout), layout, themeColor, - block: { - widthRatio: 0.22, - minWidth: 200, - maxWidth: 260, - height: 260, - padding: 12, - gap: 40, - style: { fill: '#ffffff', stroke: '#d8deea', lineWidth: 1, cornerRadius: 8 } - }, - image: { gap: 0 }, - title: commonTitle, - content: commonContent, line: commonLine }); -// portrait:上下预留 50px,中轴贯穿,block.height 由 transformer 自适应 +// portrait:默认 block.height = regionHeight / count,imageHeight = blockHeight * 0.4, +// contentHeight = blockHeight * 0.6;底部 padding 默认 = contentHeight,由 transformer 自动应用。 const createPortraitSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ type: 'storyline', - padding: [50, 20, 50, 20], + height: WIDTH, + width: HEIGHT, data: buildData(layout), layout, - themeColor, - block: { - widthRatio: 0.28, - minWidth: 220, - maxWidth: 320, - padding: 12, - gap: 40, - style: { fill: '#ffffff', stroke: '#d8deea', lineWidth: 1, cornerRadius: 8 } - }, - image: { gap: 0, showBackground: true }, - title: commonTitle, - content: commonContent, - line: commonLine + themeColor }); // arc:弧形布局,通过 direction 切换 dome('up')/ bowl('down') const createArcSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ type: 'storyline', padding: [50, 20, 100, 20], + width: WIDTH, + height: HEIGHT, data: buildData(layout), - layout: { type: 'arc', direction: 'down' }, + layout: { type: 'arc', direction: 'up' }, themeColor, - block: { - widthRatio: 0.28, - minWidth: 220, - maxWidth: 320, - height: 300, - padding: 12, - gap: 40, - style: { fill: '#ffffff', stroke: '#d8deea', lineWidth: 1, cornerRadius: 8 } - }, - image: { position: 'left', gap: 12 }, - title: commonTitle, - content: commonContent, - line: commonLine, centerImage: { image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' } @@ -176,11 +156,11 @@ const createArcSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ // clock:环绕式时间线,需要 centerImage 作为盘心 const createClockSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ type: 'storyline', - width: 1600, - height: 700, + height: HEIGHT, + width: WIDTH, padding: [20, 20, 50, 20], layout: 'clock', - themeColor: '#C8102E', + themeColor, // background: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', data: [ { @@ -197,7 +177,7 @@ const createClockSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ title: '贝利天才登场', content: '1958年瑞典世界杯成为足球新王登基的舞台。年仅十七岁的贝利首次代表巴西出战,在四分之一决赛对威尔士贡献关键进球,半决赛对法国上演帽子戏法,决赛对东道主瑞典再度梅开二度,帮助巴西首夺世界杯冠军。', - image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1958.png' + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' }, { id: 'mexico-1986', @@ -206,7 +186,7 @@ const createClockSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ '1986年墨西哥世界杯由马拉多纳一人定义。四分之一决赛对英格兰,' + '他先用左手将球送入网窝制造『上帝之手』,紧接着又从中圈带球连过五人攻入世纪进球,' + '让阿根廷在马岛战争阴影下挣得舆论高地。半决赛对比利时再献两粒精彩入球,最终阿根廷3比2夺冠。', - image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1986.png' + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' }, { id: 'france-1998', @@ -215,14 +195,14 @@ const createClockSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ '1998年法国世界杯由东道主自己谱写童话。决赛在圣丹尼新落成的法兰西大球场进行,' + '齐达内两次起跳头槌破门,将卫冕冠军巴西打懵,最终法国3比0大胜首夺世界杯。' + '比赛终场哨响时,香榭丽舍大街涌入百万球迷,蓝白红的海洋与齐达内剪影一同映在凯旋门上。', - image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1998.png' + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' }, { id: 'germany-2014', title: '战车碾过马拉卡纳', content: '2014年巴西世界杯德国队成为最大赢家。半决赛德国在贝洛奥里藏特7比1血洗东道主巴西,决赛在传奇的马拉卡纳球场进行,加时赛第113分钟,戈策胸停凌空抽射打进绝杀,德国时隔24年再夺世界杯。', - image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-2014.png' + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' }, { id: 'qatar-2022', @@ -235,62 +215,18 @@ const createClockSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' } ], - block: { - widthRatio: 0.28, - minWidth: 220, - maxWidth: 320, - height: 300, - padding: 12, - gap: 40, - style: { - fill: 'rgba(255,255,255,0.92)', - stroke: 'rgba(200,16,46,0.2)', - lineWidth: 1, - cornerRadius: 8 - } - }, - image: { - gap: 12, - width: 300, - height: 300 - }, - title: { - style: { - fontSize: 15, - fontWeight: 800, - fill: '#C8102E' - } - }, - content: { - style: { - fontSize: 12, - lineHeight: 17, - fill: '#4a4a4a' - } - }, - line: { - type: 'line', - showArrow: true, - style: { - lineWidth: 2, - lineCap: 'round', - lineJoin: 'round', - lineDash: [8, 4] - } - }, centerImage: { - image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', - width: 300, - height: 300, - style: { - width: 300, - height: 300, - cornerRadius: 150 - } + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + // width: 300, + // height: 300, + // style: { + // width: 300, + // height: 300, + // cornerRadius: 150 + // } } }); -// 默认 / ladder / spiral 等布局共用一份 spec const createDefaultSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ type: 'storyline', padding: 20, @@ -316,40 +252,11 @@ const createDefaultSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ const createWingSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ type: 'storyline', padding: [40, 40, 40, 40], + height: WIDTH, + width: HEIGHT, data: buildData(layout), layout: { type: 'wing', direction: 'right' }, - themeColor, - block: { - widthRatio: 0.32, - minWidth: 280, - maxWidth: 360, - padding: 20, - style: { fill: '#ffffff', stroke: '#d8deea', lineWidth: 1, cornerRadius: 8 } - }, - image: { width: 96, height: 96 }, - title: { - style: { - fontSize: 22, - fontWeight: 800, - lineHeight: 28, - fill: themeColor - } - }, - content: { - style: { - fontSize: 12, - lineHeight: 17, - fill: '#1f2430' - } - }, - line: { - visible: true, - style: { - // 丝带起点窄、终点宽,模拟信息图主脉络 - startWidth: 50, - endWidth: 350 - } as any - } + themeColor }); // ladder:参考 Bauhaus 信息图 —— 中央倾斜大字 headline + 两侧错落 block @@ -357,7 +264,7 @@ const createWingSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ const createLadderSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ type: 'storyline', width: 1600, - height: 500, + height: 900, padding: 20, layout: { type: 'ladder', direction: 'up', headline: 'ladder' }, themeColor: '#C8102E', @@ -369,7 +276,7 @@ const createLadderSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ content: '1930年7月,国际足联首届世界杯在乌拉圭蒙得维的亚开幕,仅有十三支球队参赛。' + '东道主乌拉圭坐镇世纪球场,决赛中以4比2逆转近邻阿根廷,捧起了雷米特金杯。', - image: 'assets/node-uruguay-1930.png' + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' }, { id: 'brazil-1958', @@ -378,7 +285,7 @@ const createLadderSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ '1958年瑞典世界杯成为足球新王登基的舞台。年仅十七岁的贝利首次代表巴西出战,' + '在四分之一决赛对威尔士贡献关键进球,半决赛对法国上演帽子戏法,' + '决赛对东道主瑞典再度梅开二度,帮助巴西首夺世界杯冠军。', - image: 'assets/node-brazil-1958.png' + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' }, { id: 'mexico-1986', @@ -387,7 +294,7 @@ const createLadderSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ '1986年墨西哥世界杯由马拉多纳一人定义。四分之一决赛对英格兰,' + '他先用左手将球送入网窝制造『上帝之手』,紧接着又从中圈带球连过五人攻入世纪进球,' + '让阿根廷在马岛战争阴影下挣得舆论高地。', - image: 'assets/node-mexico-1986.png' + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' }, { id: 'france-1998', @@ -395,7 +302,7 @@ const createLadderSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ content: '1998年法国世界杯由东道主自己谱写童话。决赛在圣丹尼新落成的法兰西大球场进行,' + '齐达内两次起跳头槌破门,将卫冕冠军巴西打懵,最终法国3比0大胜首夺世界杯。', - image: 'assets/node-france-1998.png' + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' }, { id: 'germany-2014', @@ -404,7 +311,7 @@ const createLadderSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ '2014年巴西世界杯德国队成为最大赢家。半决赛德国在贝洛奥里藏特7比1血洗东道主巴西,' + '决赛在传奇的马拉卡纳球场进行,加时赛第113分钟,戈策胸停凌空抽射打进绝杀,' + '德国时隔24年再夺世界杯。', - image: 'assets/node-germany-2014.png' + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' }, { id: 'qatar-2022', @@ -414,53 +321,9 @@ const createLadderSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ '阿根廷与法国上演被誉为史上最经典的对决。梅西梅开二度,' + '姆巴佩则上演世界杯决赛六十五年来首个帽子戏法,常规及加时赛战成3比3。' + '点球大战中阿根廷4比2取胜。', - image: 'assets/node-qatar-2022.png' - } - ], - block: { - widthRatio: 0.28, - minWidth: 220, - maxWidth: 320, - height: 192, - padding: 12, - gap: 40, - style: { - fill: 'rgba(255,255,255,0.92)', - stroke: 'rgba(200,16,46,0.18)', - lineWidth: 1, - cornerRadius: 8 - } - }, - image: { - position: 'left', - gap: 12 - }, - title: { - style: { - fontSize: 14, - fontWeight: 700, - fill: '#1f2533', - fontFamily: '"Times New Roman", Times, "Songti SC", "SimSun", serif' - } - }, - content: { - style: { - fontSize: 12, - lineHeight: 17, - fill: '#596579', - fontFamily: '"Songti SC", "STSong", "SimSun", serif' - } - }, - line: { - type: 'line', - showArrow: true, - style: { - lineWidth: 1.5, - lineCap: 'round', - lineJoin: 'round', - lineDash: [6, 5] + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' } - } + ] }); const specBuilderByLayout: Partial IStorylineSpec>> = { @@ -513,7 +376,7 @@ const run = () => { window.vchart = cs; }; - select.value = layouts[2]; + select.value = 'clock'; render(select.value as StorylineLayoutType); select.addEventListener('change', () => { diff --git a/packages/vchart-extension/src/charts/storyline/interface.ts b/packages/vchart-extension/src/charts/storyline/interface.ts index 97b66fdc86..c9759b640d 100644 --- a/packages/vchart-extension/src/charts/storyline/interface.ts +++ b/packages/vchart-extension/src/charts/storyline/interface.ts @@ -24,10 +24,15 @@ export interface IStorylineBlock { image?: string | HTMLImageElement | HTMLCanvasElement; /** * 绘制在主 image 背后的装饰图(如 portrait 布局的错位 shadow image)。 - * 仅在对应布局的 image.showBackground 为 true 时生效。 * 若未配置,则不会绘制装饰 image。 */ subImage?: string | HTMLImageElement | HTMLCanvasElement; + /** + * 时间节点文本(如 "2012")。 + * 仅 portrait 布局生效:在中轴 rect 上沿每个 block 的 center.y 处纵向绘制。 + * 配合 spec.marker 控制样式与显隐。 + */ + marker?: string; datum?: unknown; } @@ -85,13 +90,10 @@ export interface IStorylineImageSpec extends IMarkSpec { position?: StorylineImagePosition; gap?: number; /** - * 是否展示 image 背后的装饰图元(halo / shadow / 背景 rect 等)。 - * 不同布局对应的装饰图元不同: - * - wing: 圆形 halo symbol - * - portrait: 错位 shadow image + mask - * - clock: 楔形/圆形背景 rect - * - dome / bowl / landscape: image-bg(无图时的占位 rect 不受此开关影响) + * 是否展示 image 背后的白色背景 rect(白底 + 主题色描边)。 + * portrait / landscape / wing 等布局支持。 * 默认 false(不展示)。 + * 注:不影响 subImage(错位装饰图元的显隐。 */ showBackground?: boolean; } @@ -124,5 +126,10 @@ export interface IStorylineSpec extends Omit; themeColor?: string; } diff --git a/packages/vchart-extension/src/charts/storyline/layout.ts b/packages/vchart-extension/src/charts/storyline/layout.ts index c3ee9c5d1a..5e1aacbf47 100644 --- a/packages/vchart-extension/src/charts/storyline/layout.ts +++ b/packages/vchart-extension/src/charts/storyline/layout.ts @@ -55,7 +55,9 @@ export interface StorylineComputeOptions { const DEFAULT_LAYOUT: StorylineLayoutType = 'landscape'; const DEFAULT_PADDING = 24; -export const normalizePadding = (padding?: number | [number, number, number, number]): StorylinePadding => { +export const normalizePadding = ( + padding?: number | [number, number, number, number] | { top?: number; right?: number; bottom?: number; left?: number } +): StorylinePadding => { if (Array.isArray(padding)) { return { top: padding[0] ?? 0, @@ -64,7 +66,15 @@ export const normalizePadding = (padding?: number | [number, number, number, num left: padding[3] ?? 0 }; } - const value = padding ?? DEFAULT_PADDING; + if (padding && typeof padding === 'object' && 'top' in padding) { + return { + top: (padding as { top?: number }).top ?? 0, + right: (padding as { right?: number }).right ?? 0, + bottom: (padding as { bottom?: number }).bottom ?? 0, + left: (padding as { left?: number }).left ?? 0 + }; + } + const value = (padding as number | undefined) ?? DEFAULT_PADDING; return { top: value, right: value, bottom: value, left: value }; }; diff --git a/packages/vchart-extension/src/charts/storyline/layouts/arc.ts b/packages/vchart-extension/src/charts/storyline/layouts/arc.ts index 73da492352..0c643cb09a 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/arc.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/arc.ts @@ -9,7 +9,6 @@ import { getRegionGeometry, getThemeColor, normalizeLayout, - normalizePadding, omitImageLayoutSpec, resolveBlockWidth, withAlpha @@ -19,20 +18,27 @@ import { // - direction = 'up'(默认):穹顶 —— centerImage 贴底,弧线在 centerImage 上方 // - direction = 'down':碗形 —— centerImage 贴顶,弧线在 centerImage 下方 // image 默认为圆形,ARC_BLOCK_IMAGE_SIZE 即圆的直径 -const ARC_BLOCK_IMAGE_SIZE = 140; +const ARC_BLOCK_IMAGE_SIZE = 240; +// 圆形 image 的边框环厚度(圆形描边宽度) +const ARC_BLOCK_IMAGE_BORDER = 3; +// 圆形 image 背景环相对 image 的外扩量(始终渲染一个比 image 略大的圆形 symbol 作为背景环) +const ARC_BLOCK_IMAGE_HALO_PADDING = 6; const ARC_TEXT_GAP_FROM_IMAGE = 10; -const ARC_TITLE_LINE_HEIGHT = 19; -const ARC_CONTENT_LINE_HEIGHT = 17; -const ARC_CONTENT_FONT_SIZE = 12; +const ARC_TITLE_FONT_SIZE = 32; +const ARC_TITLE_LINE_HEIGHT = 34; +const ARC_CONTENT_LINE_HEIGHT = 26; +const ARC_CONTENT_FONT_SIZE = 20; // title + content 区域总高度(默认 240px,溢出由富文本 heightLimit + ellipsis 自动截断) const ARC_TEXT_BOX_HEIGHT = 240; const ARC_TITLE_TO_CONTENT_GAP = 4; // 引导线与 title/content 之间的水平间距 -const ARC_TEXT_LEFT_PADDING = 20; +const ARC_TEXT_PADDING = 20; // title/content 区域的最小宽度,确保文字有足够展示空间,不受 image 宽度限制 -const ARC_TEXT_BOX_MIN_WIDTH = 200; +const ARC_TEXT_BOX_MIN_WIDTH = 240; // centerImage 边长相对 inner 短边的比例(强制正方形,避免 cover 模式裁切图片) -const ARC_CENTER_IMAGE_SIZE_RATIO = 0.4; +const ARC_CENTER_IMAGE_SIZE_RATIO = 0.55; +// centerImage 相对 centerSymbol 的比例:image 直径 = symbol 直径 * 这个值(0.8 让 image 略小于 symbol,露出环形背景) +const ARC_CENTER_IMAGE_TO_SYMBOL_RATIO = 0.8; // 弧线最高/最低点距离 centerImage 顶部/底部的距离 const ARC_GAP_FROM_CENTER_IMAGE = 200; @@ -45,20 +51,17 @@ const isDownArc = (spec: IStorylineSpec) => normalizeLayout(spec.layout).directi */ const getArcCenterImageRect = (spec: IStorylineSpec, ctx: LayoutContext) => { const { width, height, startX, startY } = getRegionGeometry(ctx); - const padding = normalizePadding(spec.block?.padding); - const innerWidth = Math.max(width - padding.left - padding.right, 1); - const innerHeight = Math.max(height - padding.top - padding.bottom, 1); - // 取 inner 短边作为基准,使 rect 始终为正方形(cover 模式下不会裁切方形图片) + // width/height 已经是 VChart 减去 spec.padding 后的 region 大小 + // 不要再重复减去 padding,直接用 region 几何信息定位 + const innerWidth = Math.max(width, 1); + const innerHeight = Math.max(height, 1); + // 取 inner 短边作为基准,使 rect 始终为正方形 const baseSize = Math.min(innerWidth, innerHeight) * ARC_CENTER_IMAGE_SIZE_RATIO; const w = Math.max(spec.centerImage?.width ?? baseSize, 80); const h = Math.max(spec.centerImage?.height ?? baseSize, 80); - const cx = startX + padding.left + innerWidth / 2; + const cx = startX + innerWidth / 2; const isDown = isDownArc(spec); - const top = isDown - ? // bowl:紧贴顶部 - startY + padding.top - : // dome:紧贴底部 - startY + padding.top + innerHeight - h; + const top = isDown ? startY : startY + innerHeight - h; return { x: cx - w / 2, y: top, width: w, height: h }; }; @@ -82,8 +85,8 @@ const getArcCenterImageRect = (spec: IStorylineSpec, ctx: LayoutContext) => { */ const getArcGeometry = (spec: IStorylineSpec, ctx: LayoutContext) => { const { width, startX } = getRegionGeometry(ctx); - const blockPadding = normalizePadding(spec.block?.padding); - const innerWidth = Math.max(width - blockPadding.left - blockPadding.right, 1); + // width 已经是 VChart 减去 spec.padding 后的 region 宽度 + const innerWidth = Math.max(width, 1); const blockWidth = resolveBlockWidth(spec, width); const layoutOpt = normalizeLayout(spec.layout); const isDown = layoutOpt.direction === 'down'; @@ -110,7 +113,7 @@ const getArcGeometry = (spec: IStorylineSpec, ctx: LayoutContext) => { cy = centerBottom - ry * sinStart; } return { - cx: startX + blockPadding.left + innerWidth / 2, + cx: startX + innerWidth / 2, cy, rx, ry, @@ -216,6 +219,9 @@ export const buildArcCenterImageMark = (spec: IStorylineSpec): IExtensionGroupMa name: 'storyline-arc-center', zIndex: LayoutZIndex.Mark, children: [ + // centerImage 底层:圆形 symbol(直径 = imageRect 的边长,保证视觉上是真正的圆) + // - 有图时:浅色渐变底,仅作细微的底色衬托 + // - 无图时:纯白色圆形占位,保持视觉上的圆形轮廓 { type: 'symbol', name: 'storyline-arc-center-symbol', @@ -231,10 +237,10 @@ export const buildArcCenterImageMark = (spec: IStorylineSpec): IExtensionGroupMa }, size: (_d: unknown, ctx: LayoutContext) => { const r = getArcCenterImageRect(spec, ctx); - return Math.max(r.width, r.height) * 1.1; + return Math.max(r.width, r.height); }, symbolType: 'circle', - fill: symbolGradient, + fill: hasImage ? symbolGradient : '#ffffff', stroke: themeColor, lineWidth: 2 } @@ -246,10 +252,52 @@ export const buildArcCenterImageMark = (spec: IStorylineSpec): IExtensionGroupMa interactive: false, ...spec.centerImage, style: { - x: (_d: unknown, ctx: LayoutContext) => getArcCenterImageRect(spec, ctx).x, - y: (_d: unknown, ctx: LayoutContext) => getArcCenterImageRect(spec, ctx).y, - width: (_d: unknown, ctx: LayoutContext) => getArcCenterImageRect(spec, ctx).width, - height: (_d: unknown, ctx: LayoutContext) => getArcCenterImageRect(spec, ctx).height, + x: (_d: unknown, ctx: LayoutContext) => { + const r = getArcCenterImageRect(spec, ctx); + const userWidth = (spec.centerImage?.style as { width?: number } | undefined)?.width; + const w = + typeof userWidth === 'number' + ? userWidth + : Math.max(r.width, r.height) * ARC_CENTER_IMAGE_TO_SYMBOL_RATIO; + return r.x + (r.width - w) / 2; + }, + y: (_d: unknown, ctx: LayoutContext) => { + const r = getArcCenterImageRect(spec, ctx); + const userHeight = (spec.centerImage?.style as { height?: number } | undefined)?.height; + const h = + typeof userHeight === 'number' + ? userHeight + : Math.max(r.width, r.height) * ARC_CENTER_IMAGE_TO_SYMBOL_RATIO; + return r.y + (r.height - h) / 2; + }, + width: (_d: unknown, ctx: LayoutContext) => { + const r = getArcCenterImageRect(spec, ctx); + const userWidth = (spec.centerImage?.style as { width?: number } | undefined)?.width; + return typeof userWidth === 'number' + ? userWidth + : Math.max(r.width, r.height) * ARC_CENTER_IMAGE_TO_SYMBOL_RATIO; + }, + height: (_d: unknown, ctx: LayoutContext) => { + const r = getArcCenterImageRect(spec, ctx); + const userHeight = (spec.centerImage?.style as { height?: number } | undefined)?.height; + return typeof userHeight === 'number' + ? userHeight + : Math.max(r.width, r.height) * ARC_CENTER_IMAGE_TO_SYMBOL_RATIO; + }, + cornerRadius: (_d: unknown, ctx: LayoutContext) => { + const r = getArcCenterImageRect(spec, ctx); + const userWidth = (spec.centerImage?.style as { width?: number } | undefined)?.width; + const userHeight = (spec.centerImage?.style as { height?: number } | undefined)?.height; + const w = + typeof userWidth === 'number' + ? userWidth + : Math.max(r.width, r.height) * ARC_CENTER_IMAGE_TO_SYMBOL_RATIO; + const h = + typeof userHeight === 'number' + ? userHeight + : Math.max(r.width, r.height) * ARC_CENTER_IMAGE_TO_SYMBOL_RATIO; + return Math.max(w, h) / 2; + }, image: spec.centerImage?.image, repeatX: 'no-repeat', repeatY: 'no-repeat', @@ -260,19 +308,6 @@ export const buildArcCenterImageMark = (spec: IStorylineSpec): IExtensionGroupMa const r = getArcCenterImageRect(spec, ctx); return [r.x + r.width / 2, r.y + r.height / 2]; }, - // 若用户在 style 里覆盖了 width/height,自动追加 dx/dy 让图片仍以 rect 中心为中心 - dx: (_d: unknown, ctx: LayoutContext) => { - const r = getArcCenterImageRect(spec, ctx); - const userWidth = (spec.centerImage?.style as { width?: number } | undefined)?.width; - const w = typeof userWidth === 'number' ? userWidth : r.width; - return (r.width - w) / 2; - }, - dy: (_d: unknown, ctx: LayoutContext) => { - const r = getArcCenterImageRect(spec, ctx); - const userHeight = (spec.centerImage?.style as { height?: number } | undefined)?.height; - const h = typeof userHeight === 'number' ? userHeight : r.height; - return (r.height - h) / 2; - }, ...spec.centerImage?.style } } as ICustomMarkSpec<'image'>) @@ -281,8 +316,8 @@ export const buildArcCenterImageMark = (spec: IStorylineSpec): IExtensionGroupMa }; }; -const getArcBlockMetrics = (spec: IStorylineSpec) => { - const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 18); +const getArcBlockMetrics = (spec: IStorylineSpec, index: number = 0) => { + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? ARC_TITLE_FONT_SIZE); const titleLineHeight = Number( (spec.title?.style as any)?.lineHeight ?? Math.max(ARC_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.35)) ); @@ -293,23 +328,41 @@ const getArcBlockMetrics = (spec: IStorylineSpec) => { const textHeight = ARC_TEXT_BOX_HEIGHT; const contentHeight = Math.max(textHeight - titleLineHeight - titleToContentGap, contentLineHeight); - const imageWidth = spec.image?.width ?? ARC_BLOCK_IMAGE_SIZE; - const imageHeight = spec.image?.height ?? ARC_BLOCK_IMAGE_SIZE; + // 强制 image 为正方形(直径),保证圆形裁切有效 + const imageDiameter = Math.max(spec.image?.width ?? ARC_BLOCK_IMAGE_SIZE, spec.image?.height ?? ARC_BLOCK_IMAGE_SIZE); + const imageWidth = imageDiameter; + const imageHeight = imageDiameter; const isDown = isDownArc(spec); - // image 位于 block 中心,title/content: - // - up(dome):在 image 上方; - // - down(bowl):在 image 下方 + // content 默认宽度 = 图表宽度 / (block 数量 + 1),让内容沿弧线均匀分布; + // 用户可通过 spec.block?.width 覆盖;同时保留一个下限避免极端情况下不可见。 + const count = Math.max(spec.data?.length ?? 0, 1); + const canvasWidth = Number((spec.width as number | undefined) ?? imageWidth * count); + const defaultTextWidth = Math.round(canvasWidth / (count + 1)); + const textBoxWidth = Math.max( + Number((spec.block as { width?: number } | undefined)?.width ?? defaultTextWidth), + ARC_TEXT_BOX_MIN_WIDTH + ); + + // 前 1/2 为左侧(奇数 count 时中间块也算左侧),右侧为后 1/2; + // 左侧 title/content 右对齐(贴引导线),右侧 title/content 左对齐(贴引导线) + const isLeftSide = index < count / 2; + const textAlign: 'left' | 'right' = isLeftSide ? 'right' : 'left'; + + // image 位于 block 中心(x=0) const imageBox = { x: -imageWidth / 2, y: -imageHeight / 2, width: imageWidth, height: imageHeight }; - const textBoxWidth = Math.max(imageWidth - ARC_TEXT_LEFT_PADDING, ARC_TEXT_BOX_MIN_WIDTH); + // 左侧:text 右对齐,text 右边缘与引导线(x=0)保持 ARC_TEXT_PADDING 距离 + // 右侧:text 左对齐,text 左边缘与引导线(x=0)保持 ARC_TEXT_PADDING 距离 + // 引导线 rect 固定 center 在 x=0([-1, 1]) + const textBoxX = isLeftSide ? -ARC_TEXT_PADDING : ARC_TEXT_PADDING; const textBox = { - x: -imageWidth / 2 + ARC_TEXT_LEFT_PADDING, + x: textBoxX, y: isDown ? imageBox.y + imageHeight + ARC_TEXT_GAP_FROM_IMAGE : imageBox.y - ARC_TEXT_GAP_FROM_IMAGE - textHeight, width: textBoxWidth, height: textHeight @@ -328,7 +381,8 @@ const getArcBlockMetrics = (spec: IStorylineSpec) => { imageBox, textBox, contentBox, - isDown + isDown, + textAlign }; }; @@ -340,15 +394,17 @@ export const buildArcBlockMark = ( const hasImage = !!block.image; const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; const themeColor = getThemeColor(spec); - const metrics = getArcBlockMetrics(spec); + const metrics = getArcBlockMetrics(spec, index); - // 引导线 rect:贯穿 image 端面 → text 远端 - // - up(dome):从 textBox.y 到 imageBox.y(text 在 image 上方) - // - down(bowl):从 imageBox 底端 到 textBox 底端(text 在 image 下方) + // 引导线 rect:从 image 圆心(group x=0)垂直连接到 text 近端边缘 + // - up(dome):从 image 顶部(imageBox.y)向上到 textBox 顶部(textBox.y) + // - down(bowl):从 image 底部(imageBox.y + imageBox.height)向下到 textBox 底部(textBox.y + textBox.height) const connectorY = metrics.isDown ? metrics.imageBox.y + metrics.imageBox.height : metrics.textBox.y; const connectorHeight = metrics.isDown ? Math.max(metrics.textBox.y + metrics.textBox.height - (metrics.imageBox.y + metrics.imageBox.height), 0) : Math.max(metrics.imageBox.y - metrics.textBox.y, 0); + // 引导线宽度固定为 2,center 对齐 x=0 + const connectorX = -1; return { type: 'group' as any, @@ -360,13 +416,14 @@ export const buildArcBlockMark = ( y: (_d: unknown, ctx: LayoutContext) => getArcBlockCenter(spec, ctx, index).y }, children: [ - // title / content 左侧的垂直引导线 + // title / content 与 image 之间的垂直引导线(zIndex 最低,作为装饰) { type: 'rect', name: `storyline-block-connector-${index}`, interactive: false, + zIndex: LayoutZIndex.Mark + 2, style: { - x: metrics.imageBox.x, + x: connectorX, y: connectorY, width: 2, height: connectorHeight, @@ -379,12 +436,14 @@ export const buildArcBlockMark = ( type: 'image', name: `storyline-block-image-${index}`, interactive: false, + zIndex: LayoutZIndex.Mark + 3, ...omitImageLayoutSpec(spec.image), style: { x: metrics.imageBox.x, y: metrics.imageBox.y, width: metrics.imageBox.width, height: metrics.imageBox.height, + cornerRadius: Math.min(metrics.imageBox.width, metrics.imageBox.height) / 2, image: block.image, repeatX: 'no-repeat', repeatY: 'no-repeat', @@ -394,25 +453,44 @@ export const buildArcBlockMark = ( } } as ICustomMarkSpec<'image'>) : ({ - type: 'rect', + type: 'symbol', name: `storyline-block-image-bg-${index}`, interactive: false, + zIndex: LayoutZIndex.Mark + 3, style: { - x: metrics.imageBox.x, - y: metrics.imageBox.y, - width: metrics.imageBox.width, - height: metrics.imageBox.height, - cornerRadius: Math.min(metrics.imageBox.width, metrics.imageBox.height) / 2, + x: 0, + y: 0, + size: Math.min(metrics.imageBox.width, metrics.imageBox.height), + symbolType: 'circle', fill: '#ffffff', stroke: themeColor, lineWidth: 2 } - } as ICustomMarkSpec<'rect'>), + } as ICustomMarkSpec<'symbol'>), + // 圆形 image 的外层装饰环(默认不展示,仅当 spec.image.showBackground === true 时渲染) + spec.image?.showBackground === true + ? ({ + type: 'symbol', + name: `storyline-block-image-halo-${index}`, + interactive: false, + zIndex: LayoutZIndex.Mark + 3, + style: { + x: 0, + y: 0, + size: Math.min(metrics.imageBox.width, metrics.imageBox.height) + ARC_BLOCK_IMAGE_HALO_PADDING * 2, + symbolType: 'circle', + fill: 'transparent', + stroke: themeColor, + lineWidth: ARC_BLOCK_IMAGE_BORDER + } + } as ICustomMarkSpec<'symbol'>) + : null, block.title ? ({ type: 'text', name: `storyline-block-title-${index}`, interactive: false, + zIndex: LayoutZIndex.Mark + 5, ...spec.title, style: { x: metrics.textBox.x, @@ -426,7 +504,7 @@ export const buildArcBlockMark = ( stroke: '#fff', lineWidth: 5, lineJoin: 'round', - textAlign: 'left', + textAlign: metrics.textAlign, textBaseline: 'top', ...spec.title?.style } @@ -437,6 +515,7 @@ export const buildArcBlockMark = ( type: 'text', name: `storyline-block-content-${index}`, interactive: false, + zIndex: LayoutZIndex.Mark + 4, ...spec.content, textType: 'rich', style: { @@ -446,10 +525,13 @@ export const buildArcBlockMark = ( height: metrics.contentBox.height, maxLineWidth: metrics.contentBox.width, heightLimit: metrics.contentBox.height, - text: buildRichContent(contentText, spec), - fontSize: ARC_CONTENT_FONT_SIZE, - lineHeight: ARC_CONTENT_LINE_HEIGHT, - textAlign: 'left', + text: buildRichContent(contentText, spec, { + fontSize: metrics.contentFontSize, + lineHeight: metrics.contentLineHeight, + fill: '#596173', + align: metrics.textAlign + }), + textAlign: metrics.textAlign, textBaseline: 'top', wordBreak: 'break-word', ellipsis: '...', diff --git a/packages/vchart-extension/src/charts/storyline/layouts/clock.ts b/packages/vchart-extension/src/charts/storyline/layouts/clock.ts index 62136dca2d..25a75c6d28 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/clock.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/clock.ts @@ -32,24 +32,24 @@ import { */ // ===== 半径配置(按可用半径的比例划分各圈层)===== -const CLOCK_CENTER_RADIUS_RATIO = 0.5; // 中心圆半径 -const CLOCK_CENTER_IMAGE_INSET_RATIO = 0.86; // centerImage 相对中心圆的尺寸比例(留出环形空隙) -const CLOCK_ORBIT_RATIO = 0.58; // 虚线轨道半径 -const CLOCK_DOT_RATIO = 0.58; // 圆形小图(dot)中心所在半径(与轨道重合) -const CLOCK_TEXT_INNER_RATIO = 0.7; // block 文字段起始半径 -const CLOCK_TEXT_MAX_WIDTH = 200; // 文字段最大宽度,避免靠近正上/正下的 block 占满整个画布半宽 +const CLOCK_CENTER_RADIUS_RATIO = 0.6; // 中心圆半径(更大) +const CLOCK_CENTER_IMAGE_INSET_RATIO = 0.9; // centerImage 相对中心圆的尺寸比例(留出环形空隙) +const CLOCK_ORBIT_RATIO = 0.68; // 虚线轨道半径 +const CLOCK_DOT_RATIO = 0.68; // 圆形小图(dot)中心所在半径(与轨道重合) +const CLOCK_TEXT_INNER_RATIO = 0.92; // block 文字段起始半径(距离圆心更远) +const CLOCK_TEXT_MAX_WIDTH = 280; // 文字段最大宽度 // ===== 元素尺寸 ===== -const CLOCK_DOT_DIAMETER_RATIO = 0.24; // dot 直径相对 R +const CLOCK_DOT_DIAMETER_RATIO = 0.32; // dot 直径相对 R(更大) const CLOCK_LEAD_LINE_GAP = 6; // dot 到引线起点的间距 px const CLOCK_TEXT_GAP_FROM_LEAD = 8; // 引线到文字的间距 px const CLOCK_ORBIT_DASH = [4, 4]; // ===== 文字 ===== -const CLOCK_TITLE_FONT_SIZE = 18; -const CLOCK_TITLE_LINE_HEIGHT = 24; -const CLOCK_CONTENT_FONT_SIZE = 11; -const CLOCK_CONTENT_LINE_HEIGHT = 15; +const CLOCK_TITLE_FONT_SIZE = 22; +const CLOCK_TITLE_LINE_HEIGHT = 28; +const CLOCK_CONTENT_FONT_SIZE = 16; +const CLOCK_CONTENT_LINE_HEIGHT = 22; // ===== 几何 ===== @@ -68,7 +68,15 @@ const getClockGeometry = (spec: IStorylineSpec, ctx: LayoutContext): ClockGeomet const innerHeight = Math.max(height - padding.top - padding.bottom, 1); const cx = startX + padding.left + innerWidth / 2; const cy = startY + padding.top + innerHeight / 2; - const R = Math.max(Math.min(innerWidth, innerHeight) / 2, 1); + // R 需要预留 text 向外延伸的空间: + // - title 在 anchor 朝向圆心一侧(不占用外圈空间) + // - content 在 anchor 远离圆心一侧,需要预留 content 的高度 + // - 水平方向:text 从 0.92R 向外延伸 CLOCK_TEXT_MAX_WIDTH + const textReserveX = CLOCK_TEXT_MAX_WIDTH; + const textReserveY = 4 + CLOCK_CONTENT_LINE_HEIGHT * 4; + const rMaxX = (innerWidth / 2 - textReserveX) / CLOCK_TEXT_INNER_RATIO; + const rMaxY = (innerHeight / 2 - textReserveY) / CLOCK_TEXT_INNER_RATIO; + const R = Math.max(Math.min(rMaxX, rMaxY), 1); const count = spec.data?.length ?? 0; const step = count > 0 ? (Math.PI * 2) / count : 0; return { cx, cy, R, count, step }; @@ -145,6 +153,8 @@ export const buildClockCenterImageMark = (spec: IStorylineSpec): IExtensionGroup repeatY: 'no-repeat', imageMode: 'cover', imagePosition: 'center', + cornerRadius: (_d: unknown, ctx: LayoutContext) => + getClockGeometry(spec, ctx).R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO, // 默认锚点设为 image 中心,让 scaleX/scaleY 从中心缩放 anchor: (_d: unknown, ctx: LayoutContext) => { const g = getClockGeometry(spec, ctx); @@ -322,7 +332,8 @@ export const buildClockBlockMark = ( repeatX: 'no-repeat', repeatY: 'no-repeat', imageMode: 'cover', - imagePosition: 'center' + imagePosition: 'center', + cornerRadius: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).diameter / 2 } } as ICustomMarkSpec<'image'>) : ({ diff --git a/packages/vchart-extension/src/charts/storyline/layouts/common.ts b/packages/vchart-extension/src/charts/storyline/layouts/common.ts index 092ce03208..e5482c2cd5 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/common.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/common.ts @@ -90,14 +90,14 @@ export const resolveBlockWidth = (spec: IStorylineSpec, viewWidth: number) => { // ===== 容器几何信息(chart region rect)===== -export const getRegionGeometry = (ctx: LayoutContext) => { +export const getRegionGeometry = (ctx: LayoutContext, spec?: { width?: number; height?: number }) => { const region = ctx.chart?.getAllRegions?.()?.[0]; const regionRect = region?.getLayoutRect?.(); const regionStart = region?.getLayoutStartPoint?.(); const chartRect = ctx.chart?.getLayoutRect?.(); const bounds = ctx.getLayoutBounds?.(); - const width = Math.max(regionRect?.width ?? chartRect?.width ?? bounds?.width?.() ?? 0, 1); - const height = Math.max(regionRect?.height ?? chartRect?.height ?? bounds?.height?.() ?? 0, 1); + const width = Math.max(regionRect?.width ?? chartRect?.width ?? bounds?.width?.() ?? spec?.width ?? 0, 1); + const height = Math.max(regionRect?.height ?? chartRect?.height ?? bounds?.height?.() ?? spec?.height ?? 0, 1); return { width, height, @@ -109,9 +109,9 @@ export const getRegionGeometry = (ctx: LayoutContext) => { // ===== 布局计算(layout.ts 的封装,附加 startX/startY 平移)===== export const getLayout = (spec: IStorylineSpec, ctx: LayoutContext): StorylineLayoutResult => { - const { width, height, startX, startY } = getRegionGeometry(ctx); + const { width, height, startX, startY } = getRegionGeometry(ctx, spec); let blockWidth = resolveBlockWidth(spec, width); - let blockHeight = spec.block?.height ?? DEFAULT_BLOCK_HEIGHT; + let blockHeight = spec.block?.height ?? (isLandscape(spec) ? 320 : DEFAULT_BLOCK_HEIGHT); // landscape:图片间距固定 40,根据 block 数量自适应单个 image 宽度 if (isLandscape(spec) && !spec.block?.width) { const count = spec.data?.length ?? 0; @@ -125,13 +125,16 @@ export const getLayout = (spec: IStorylineSpec, ctx: LayoutContext): StorylineLa blockWidth = Math.max(LANDSCAPE_IMAGE_MIN_WIDTH, Math.floor(adaptive)); } } - // portrait:每个 block 在垂直方向需要容纳 image + text,整体根据 viewBox 高度均分 + // portrait:每个 block 在垂直方向需要容纳 image + text,整体根据 region 高度均分 + // blockHeight = regionHeight / (count + 1)(即每个 block 的"槽位"高度),后续 portrait.ts 中: + // imageHeight = blockHeight * 0.6 + // contentHeight = blockHeight if (isPortrait(spec) && !spec.block?.height) { const count = spec.data?.length ?? 0; if (count > 0) { - const padding = normalizePadding(spec.block?.padding); + const padding = normalizePadding(spec.layout?.padding ?? spec.block?.padding); const innerHeight = Math.max(height - padding.top - padding.bottom, 1); - blockHeight = Math.max(DEFAULT_BLOCK_HEIGHT, Math.floor(innerHeight / count)); + blockHeight = Math.max(120, Math.floor(innerHeight / (count + 1))); } } const result = computeStorylineLayout(spec.data ?? [], { @@ -170,21 +173,27 @@ export const getLayout = (spec: IStorylineSpec, ctx: LayoutContext): StorylineLa // ===== 文本 / 图像通用工具 ===== -export const buildRichContent = (contentText: string[], spec: IStorylineSpec) => { - const fontSize = Number((spec.content?.style as any)?.fontSize ?? 12); - const lineHeight = Number((spec.content?.style as any)?.lineHeight ?? 18); - const fill = (spec.content?.style as any)?.fill ?? '#596173'; +export const buildRichContent = ( + contentText: string[], + spec: IStorylineSpec, + overrides?: { fontSize?: number; lineHeight?: number; fill?: string; align?: 'left' | 'center' | 'right' } +) => { + const fontSize = Number(overrides?.fontSize ?? (spec.content?.style as any)?.fontSize ?? 18); + const lineHeight = Number(overrides?.lineHeight ?? (spec.content?.style as any)?.lineHeight ?? 26); + const fill = overrides?.fill ?? (spec.content?.style as any)?.fill ?? '#596173'; + const align = overrides?.align ?? 'left'; return { type: 'rich' as const, - text: contentText.reduce<{ text: string; fontSize: number; lineHeight: number; fill: string }[]>( + text: contentText.reduce<{ text: string; fontSize: number; lineHeight: number; fill: string; align: string }[]>( (result, paragraph, index) => { const suffix = index === contentText.length - 1 ? '' : '\n'; result.push({ text: `${paragraph}${suffix}`, fontSize, lineHeight, - fill + fill, + align }); return result; }, diff --git a/packages/vchart-extension/src/charts/storyline/layouts/default.ts b/packages/vchart-extension/src/charts/storyline/layouts/default.ts index 74586796d7..ce18a3fcee 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/default.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/default.ts @@ -128,24 +128,26 @@ export const buildDefaultBlockMark = ( height: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).block.height }, children: [ - { - type: 'rect', - name: `storyline-block-bg-${index}`, - interactive: false, - style: { - x: 0, - y: 0, - width: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).block.width, - height: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).block.height, - cornerRadius: 8, - fill: '#ffffff', - stroke: '#d7dce5', - lineWidth: 1, - shadowBlur: 6, - shadowColor: 'rgba(0, 0, 0, 0.08)', - ...spec.block?.style - } - }, + spec.block?.showBackground === true + ? ({ + type: 'rect', + name: `storyline-block-bg-${index}`, + interactive: false, + style: { + x: 0, + y: 0, + width: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).block.width, + height: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).block.height, + cornerRadius: 8, + fill: '#ffffff', + stroke: '#d7dce5', + lineWidth: 1, + shadowBlur: 6, + shadowColor: 'rgba(0, 0, 0, 0.08)', + ...spec.block?.style + } + } as ICustomMarkSpec<'rect'>) + : null, hasImage ? ({ type: 'image', @@ -198,11 +200,13 @@ export const buildDefaultBlockMark = ( x: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).textBox.x, y: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).contentBox.y, width: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).textBox.width, - text: buildRichContent(contentText, spec), + text: buildRichContent(contentText, spec, { + fontSize: 18, + lineHeight: 26, + fill: '#596173' + }), maxLineWidth: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).textBox.width, - fontSize: 12, - lineHeight: 18, heightLimit: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).contentBox.height, textAlign: 'left', diff --git a/packages/vchart-extension/src/charts/storyline/layouts/ladder.ts b/packages/vchart-extension/src/charts/storyline/layouts/ladder.ts index 5022dc73e8..f5a88fee36 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/ladder.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/ladder.ts @@ -46,11 +46,11 @@ const LADDER_DIAGONAL_LINE_WIDTH = 2; const LADDER_DIAGONAL_DASH = [12, 8]; // ladder 中 block 的默认视觉参数(比通用默认值更大,符合 Bauhaus 信息图风格) -const LADDER_BLOCK_IMAGE_SIZE = 96; +const LADDER_BLOCK_IMAGE_SIZE = 100; const LADDER_TITLE_FONT_SIZE = 28; -const LADDER_TITLE_LINE_HEIGHT = 36; -const LADDER_CONTENT_FONT_SIZE = 16; -const LADDER_CONTENT_LINE_HEIGHT = 24; +const LADDER_TITLE_LINE_HEIGHT = 26; +const LADDER_CONTENT_FONT_SIZE = 18; +const LADDER_CONTENT_LINE_HEIGHT = 26; const isDownLadder = (spec: IStorylineSpec) => normalizeLayout(spec.layout).direction === 'down'; @@ -347,10 +347,12 @@ export const buildLadderBlockMark = ( x: (_d: unknown, ctx: LayoutContext) => getTitleX(ctx), y: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).contentBox.y, width: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).textBox.width, - text: buildRichContent(contentText, spec), + text: buildRichContent(contentText, spec, { + fontSize: LADDER_CONTENT_FONT_SIZE, + lineHeight: LADDER_CONTENT_LINE_HEIGHT, + align: align as 'left' | 'right' + }), maxLineWidth: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).textBox.width, - fontSize: LADDER_CONTENT_FONT_SIZE, - lineHeight: LADDER_CONTENT_LINE_HEIGHT, heightLimit: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).contentBox.height, textBaseline: 'top', diff --git a/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts b/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts index bdcc0e3973..bb9fdd6823 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts @@ -21,11 +21,11 @@ const LANDSCAPE_DETACHED_GAP = 64; const LANDSCAPE_CONNECTOR_ARROW_SIZE = 9; const LANDSCAPE_CONNECTOR_X_RATIO = 0.2; // 引导线 x 位于 image 左侧 20% 处 const LANDSCAPE_TEXT_GAP_FROM_CONNECTOR = 12; // 文字距离引导线的水平间距 -// content 区固定为 4 行,整体 textHeight = titleLineHeight + titleGap + contentLines * contentLineHeight -const LANDSCAPE_CONTENT_LINES = 4; -const LANDSCAPE_TITLE_LINE_HEIGHT = 19; -const LANDSCAPE_CONTENT_LINE_HEIGHT = 18; -const LANDSCAPE_CONTENT_FONT_SIZE = 12; +// content 区固定为 10 行,整体 textHeight = titleLineHeight + titleGap + contentLines * contentLineHeight +const LANDSCAPE_CONTENT_LINES = 10; +const LANDSCAPE_TITLE_LINE_HEIGHT = 34; +const LANDSCAPE_CONTENT_LINE_HEIGHT = 26; +const LANDSCAPE_CONTENT_FONT_SIZE = 18; const LANDSCAPE_TITLE_TO_CONTENT_GAP = 4; /** @@ -107,7 +107,7 @@ export const buildLandscapeConnectingCurve = (spec: IStorylineSpec): IExtensionG */ const getLandscapeMetrics = (spec: IStorylineSpec, blockWidth: number, blockHeight: number, index: number) => { const padding = normalizePadding(spec.block?.padding ?? 12); - const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 18); + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 26); const titleLineHeight = Number( (spec.title?.style as any)?.lineHeight ?? Math.max(LANDSCAPE_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.35)) ); @@ -119,7 +119,11 @@ const getLandscapeMetrics = (spec: IStorylineSpec, blockWidth: number, blockHeig titleLineHeight + padding.top + padding.bottom ); const connectorGap = LANDSCAPE_DETACHED_GAP; - const contentHeight = LANDSCAPE_CONTENT_LINES * contentLineHeight; + // landscape:content 默认高度 = 图表高度 / 2,没有传 spec.height 时回退到固定行数 + const canvasHeight = spec.height as number | undefined; + const contentHeight = canvasHeight + ? Math.max(contentLineHeight * 2, Math.round(canvasHeight / 4)) + : LANDSCAPE_CONTENT_LINES * contentLineHeight; const titleToContentGap = LANDSCAPE_TITLE_TO_CONTENT_GAP; const textHeight = titleLineHeight + titleToContentGap + contentHeight; @@ -198,7 +202,7 @@ export const buildLandscapeBlockMark = ( ): IExtensionGroupMarkSpec => { const hasImage = !!block.image; const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; - const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 18); + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 26); const titleLineHeight = Number((spec.title?.style as any)?.lineHeight ?? Math.round(titleFontSize * 1.35)); const getMetrics = (ctx: LayoutContext) => { @@ -230,7 +234,8 @@ export const buildLandscapeBlockMark = ( const m = getMetrics(ctx); const cy = lb?.center?.y ?? (lb?.y ?? 0) + (lb?.height ?? 0) / 2; const blockH = lb?.height ?? spec.block?.height ?? DEFAULT_BLOCK_HEIGHT; - const stagger = (index % 2 === 0 ? -1 : 1) * blockH * 0.1; + // text 在上方时 group 往下偏移,text 在下方时 group 往上偏移 + const stagger = m.textOnTop ? blockH * 0.1 : -blockH * 0.1; return cy - m.imageBox.height / 2 + stagger; }, width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).blockWidth, @@ -334,9 +339,11 @@ export const buildLandscapeBlockMark = ( height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.height, maxLineWidth: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.width, heightLimit: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.height, - text: buildRichContent(contentText, spec), - fontSize: LANDSCAPE_CONTENT_FONT_SIZE, - lineHeight: LANDSCAPE_CONTENT_LINE_HEIGHT, + text: buildRichContent(contentText, spec, { + fontSize: LANDSCAPE_CONTENT_FONT_SIZE, + lineHeight: LANDSCAPE_CONTENT_LINE_HEIGHT, + fill: '#596173' + }), textAlign: 'left', textBaseline: 'top', wordBreak: 'break-word', diff --git a/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts b/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts index d1f0abbb9f..50eeb4a3f0 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts @@ -14,20 +14,29 @@ import { } from './common'; // portrait 布局:中轴 rect + 左右交替的 image + image 下方 title/content -const PORTRAIT_AXIS_WIDTH = 64; +// 中轴默认加宽,便于在轴上叠放 block.marker 时间节点文字(纵向逐字排列) +const PORTRAIT_AXIS_WIDTH = 96; const PORTRAIT_AXIS_PADDING = 50; // 中轴上下两端的留白 -const PORTRAIT_IMAGE_WIDTH = 180; -const PORTRAIT_IMAGE_HEIGHT = 110; +// marker 时间节点文字的默认样式(fontSize 30、白色字、贴轴对应一侧边缘) +const PORTRAIT_MARKER_FONT_SIZE = 40; +const PORTRAIT_MARKER_LINE_HEIGHT = 28; +const PORTRAIT_MARKER_AXIS_PADDING = 6; // marker 距离轴边缘的水平内边距 +// image 默认尺寸的占比规则(基于 region 平均槽位): +// - image 高度 = slotHeight * 0.6 +// - content 高度 = slotHeight * (0.6 + 0.4) +// 其中 slotHeight = regionHeight / (blockCount + 1),由 getLayout 计算。 +export const PORTRAIT_IMAGE_HEIGHT_RATIO = 0.6; +export const PORTRAIT_CONTENT_HEIGHT_RATIO = 1; const PORTRAIT_IMAGE_GAP_FROM_AXIS = 24; // image 与中轴之间的水平间距 const PORTRAIT_SHADOW_OFFSET_X = 24; // subImage 相对主 image 的水平错位量 const PORTRAIT_SHADOW_OFFSET_Y = 16; // subImage 相对主 image 的垂直错位量 const PORTRAIT_SHADOW_SCALE = 1; // subImage 与主 image 同尺寸,仅做错位偏移 -const PORTRAIT_TEXT_GAP_FROM_IMAGE = 8; -const PORTRAIT_CONTENT_LINES = 3; -const PORTRAIT_TITLE_LINE_HEIGHT = 19; -const PORTRAIT_CONTENT_LINE_HEIGHT = 18; -const PORTRAIT_CONTENT_FONT_SIZE = 12; -const PORTRAIT_TITLE_TO_CONTENT_GAP = 4; +export const PORTRAIT_TEXT_GAP_FROM_IMAGE = 8; +export const PORTRAIT_CONTENT_LINES = 3; +export const PORTRAIT_TITLE_LINE_HEIGHT = 34; +export const PORTRAIT_CONTENT_LINE_HEIGHT = 26; +const PORTRAIT_CONTENT_FONT_SIZE = 18; +export const PORTRAIT_TITLE_TO_CONTENT_GAP = 4; /** * 获取 portrait 布局的中轴 rect 尺寸:宽度固定,高度贯穿首/尾 block 中心。 @@ -64,6 +73,67 @@ export const buildPortraitAxisMark = (spec: IStorylineSpec): IExtensionGroupMark { offset: 1, color: withAlpha(themeColor, 1) } ] }; + // marker 时间节点文字:垂直方向逐字排列(每字符换行) + const markerFontSize = Number((spec.marker?.style as any)?.fontSize ?? PORTRAIT_MARKER_FONT_SIZE); + const markerLineHeight = Number((spec.marker?.style as any)?.lineHeight ?? PORTRAIT_MARKER_LINE_HEIGHT); + const markerVisible = spec.marker?.visible !== false; + // 把 "2012" 拆成 "2\n0\n1\n2",由 text mark 的多行文本能力实现纵向排列 + const splitVertical = (text: string) => text.split('').join('\n'); + + const markerMarks = markerVisible + ? (spec.data ?? []) + .map((block, index) => { + if (!block.marker) { + return null; + } + // image 在 block 左侧时(index 偶数),marker 贴轴左边缘 + 左对齐; + // image 在 block 右侧时(index 奇数),marker 贴轴右边缘 + 右对齐。 + const onLeft = index % 2 === 0; + const axisHalf = PORTRAIT_AXIS_WIDTH / 2; + const markerOffsetX = onLeft + ? -axisHalf + PORTRAIT_MARKER_AXIS_PADDING + : axisHalf - PORTRAIT_MARKER_AXIS_PADDING; + const markerTextAlign: 'left' | 'right' = onLeft ? 'left' : 'right'; + return { + type: 'text', + textType: 'rich', + name: `storyline-portrait-marker-${index}`, + interactive: false, + ...spec.marker, + style: { + x: (_d: unknown, ctx: LayoutContext) => { + const lb = getLayout(spec, ctx).blocks[index]; + return (lb?.center?.x ?? 0) + markerOffsetX; + }, + y: (_d: unknown, ctx: LayoutContext) => { + const lb = getLayout(spec, ctx).blocks[index]; + return lb?.center?.y ?? 0; + }, + text: { + type: 'rich', + text: block.marker.split('').map((char, i, arr) => ({ + text: char + (i < arr.length - 1 ? '\n' : ''), + fontSize: markerFontSize, + lineHeight: markerLineHeight, + fill: '#fff', + align: markerTextAlign + })) + }, + fontWeight: 'bold', + lineJoin: 'round', + shadowColor: 'rgba(0, 0, 0, 0.3)', + shadowBlur: 8, + shadowOffsetX: 0, + shadowOffsetY: 5, + textAlign: markerTextAlign, + textBaseline: 'middle', + ...(spec.marker?.style as any) + } + } as ICustomMarkSpec<'text'>; + }) + .filter(Boolean) + : []; + return { type: 'group' as any, name: 'storyline-portrait-axis', @@ -83,13 +153,14 @@ export const buildPortraitAxisMark = (spec: IStorylineSpec): IExtensionGroupMark width: (_d: unknown, ctx: LayoutContext) => getPortraitAxisRect(spec, ctx).width, height: (_d: unknown, ctx: LayoutContext) => getPortraitAxisRect(spec, ctx).height } - } as ICustomMarkSpec<'rect'> + } as ICustomMarkSpec<'rect'>, + ...(markerMarks as ICustomMarkSpec[]) ] }; }; const getPortraitMetrics = (spec: IStorylineSpec, blockWidth: number, blockHeight: number, index: number) => { - const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 18); + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 26); const titleLineHeight = Number( (spec.title?.style as any)?.lineHeight ?? Math.max(PORTRAIT_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.35)) ); @@ -97,13 +168,14 @@ const getPortraitMetrics = (spec: IStorylineSpec, blockWidth: number, blockHeigh const contentLineHeight = Number((spec.content?.style as any)?.lineHeight ?? PORTRAIT_CONTENT_LINE_HEIGHT); const titleToContentGap = PORTRAIT_TITLE_TO_CONTENT_GAP; - const imageWidth = spec.image?.width ?? PORTRAIT_IMAGE_WIDTH; - const imageHeight = spec.image?.height ?? PORTRAIT_IMAGE_HEIGHT; + // 默认 image 高度 = blockHeight * 0.4(blockHeight = regionHeight / count,由 getLayout 计算); + // 默认 image 宽度 = blockWidth,让 image 横向自适应单个 block 槽位宽度 + const imageWidth = spec.image?.width ?? Math.max(blockWidth, 80); + const imageHeight = spec.image?.height ?? Math.round(blockHeight * PORTRAIT_IMAGE_HEIGHT_RATIO); const minContentHeight = PORTRAIT_CONTENT_LINES * contentLineHeight; - const contentHeight = Math.max( - minContentHeight, - blockHeight - imageHeight / 2 - PORTRAIT_TEXT_GAP_FROM_IMAGE - titleLineHeight - titleToContentGap - ); + // 默认 content 高度 = blockHeight * 0.4 + const contentHeight = Math.max(minContentHeight, Math.round(blockHeight * PORTRAIT_CONTENT_HEIGHT_RATIO)); + const textHeight = titleLineHeight + titleToContentGap + contentHeight; const onLeft = index % 2 === 0; @@ -160,12 +232,10 @@ export const buildPortraitBlockMark = ( const hasImage = !!block.image; const hasSubImage = !!block.subImage; const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; - const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 18); + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 26); const titleLineHeight = Number( (spec.title?.style as any)?.lineHeight ?? Math.max(PORTRAIT_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.35)) ); - // image 背后的装饰图元(错位 shadow image + mask)默认不展示 - const showBackground = spec.image?.showBackground === true; const getMetrics = (ctx: LayoutContext) => { const lb = getLayout(spec, ctx).blocks[index]; @@ -192,7 +262,7 @@ export const buildPortraitBlockMark = ( } }, children: [ - hasSubImage && showBackground + hasSubImage ? ({ type: 'image', name: `storyline-block-shadow-image-${index}`, @@ -210,22 +280,24 @@ export const buildPortraitBlockMark = ( } } as ICustomMarkSpec<'image'>) : null, - { - type: 'rect', - name: `storyline-block-image-bg-${index}`, - interactive: false, - style: { - x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.x, - y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.y, - width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.width, - height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.height, - cornerRadius: 8, - fill: '#ffffff', - stroke: themeColor, - lineWidth: 2, - ...blockStyle - } - } as ICustomMarkSpec<'rect'>, + spec.image?.showBackground === true + ? ({ + type: 'rect', + name: `storyline-block-image-bg-${index}`, + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.y, + width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.width, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.height, + cornerRadius: 8, + fill: '#ffffff', + stroke: themeColor, + lineWidth: 2, + ...blockStyle + } + } as ICustomMarkSpec<'rect'>) + : null, hasImage ? ({ type: 'image', @@ -284,9 +356,11 @@ export const buildPortraitBlockMark = ( height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.height, maxLineWidth: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.width, heightLimit: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.height, - text: buildRichContent(contentText, spec), - fontSize: PORTRAIT_CONTENT_FONT_SIZE, - lineHeight: PORTRAIT_CONTENT_LINE_HEIGHT, + text: buildRichContent(contentText, spec, { + fontSize: PORTRAIT_CONTENT_FONT_SIZE, + lineHeight: PORTRAIT_CONTENT_LINE_HEIGHT, + fill: '#596173' + }), textAlign: 'left', textBaseline: 'top', wordBreak: 'break-word', diff --git a/packages/vchart-extension/src/charts/storyline/layouts/wing.ts b/packages/vchart-extension/src/charts/storyline/layouts/wing.ts index 2589d56c61..55af8dac8e 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/wing.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/wing.ts @@ -9,7 +9,6 @@ import { getRegionGeometry, getThemeColor, normalizeLayout, - normalizePadding, omitImageLayoutSpec, withAlpha } from './common'; @@ -21,7 +20,7 @@ import { // - 圆形 image 嵌在弧线上(中心位于弧线) // - title(年份感大字 + 主题色) + content 在 image 一侧水平展开 // - 左右交替(弧线左侧 / 右侧)让节点错落 -const WING_BLOCK_IMAGE_SIZE = 96; +const WING_BLOCK_IMAGE_SIZE = 160; const WING_TEXT_GAP_FROM_IMAGE = 14; const WING_TITLE_LINE_HEIGHT = 30; const WING_TITLE_FONT_SIZE = 22; @@ -44,9 +43,10 @@ const getWingDirection = (spec: IStorylineSpec): StorylineWingDirection => { */ const getWingArcGeometry = (spec: IStorylineSpec, ctx: LayoutContext) => { const { width, height, startX, startY } = getRegionGeometry(ctx); - const padding = normalizePadding(spec.block?.padding); - const innerWidth = Math.max(width - padding.left - padding.right, 1); - const innerHeight = Math.max(height - padding.top - padding.bottom, 1); + // width/height 已经是 VChart 减去 spec.padding 后的 region 大小 + // 不要再重复减去 padding,直接用 region 几何信息定位弧线 + const innerWidth = Math.max(width, 1); + const innerHeight = Math.max(height, 1); const layoutOpt = normalizeLayout(spec.layout); const direction = getWingDirection(spec); const defaultStart = direction === 'right' ? 110 : -70; @@ -56,9 +56,9 @@ const getWingArcGeometry = (spec: IStorylineSpec, ctx: LayoutContext) => { const ratio = layoutOpt.radiusRatio ?? 0.92; const ry = (innerHeight / 2) * ratio; const rx = innerWidth * 0.6 * ratio; - // 左翅膀锚画布左侧,右翅膀锚画布右侧 - const cx = direction === 'right' ? startX + padding.left + innerWidth - rx * 0.1 : startX + padding.left + rx * 0.1; - const cy = startY + padding.top + innerHeight / 2; + // direction='right':圆心锚在 region 右侧,弧线点在左侧;direction='left':圆心锚在左侧 + const cx = direction === 'right' ? startX + innerWidth - rx * 0.1 : startX + rx * 0.1; + const cy = startY + innerHeight / 2; return { cx, cy, rx, ry, startAngle, endAngle }; }; @@ -100,8 +100,8 @@ export const buildWingArcMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec } const themeColor = getThemeColor(spec); const lineStyle = (spec.line?.style ?? {}) as Record; - const startWidth = Math.max(Number(lineStyle.startWidth ?? 2), 0.5); - const endWidth = Math.max(Number(lineStyle.endWidth ?? lineStyle.lineWidth ?? 18), startWidth); + const startWidth = Math.max(Number(lineStyle.startWidth ?? 50), 0.5); + const endWidth = Math.max(Number(lineStyle.endWidth ?? lineStyle.lineWidth ?? 350), startWidth); return { type: 'group' as any, name: 'storyline-wing-arc', @@ -159,7 +159,10 @@ export const buildWingArcMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec }; }; -const getWingBlockMetrics = (spec: IStorylineSpec, index: number) => { +// text box 与 image 的水平间距(image 左边缘到 text box 右边缘的距离) +const WING_TEXT_IMAGE_GAP = 120; + +const getWingBlockMetrics = (spec: IStorylineSpec, ctx: LayoutContext, index: number) => { const titleFontSize = Number((spec.title?.style as Record)?.fontSize ?? WING_TITLE_FONT_SIZE); const titleLineHeight = Number( (spec.title?.style as Record)?.lineHeight ?? @@ -171,10 +174,10 @@ const getWingBlockMetrics = (spec: IStorylineSpec, index: number) => { ); const titleToContentGap = WING_TITLE_TO_CONTENT_GAP; const textHeight = WING_TEXT_BOX_HEIGHT; - const contentHeight = Math.max(textHeight - titleLineHeight - titleToContentGap, contentLineHeight); + const contentHeight = 100000; - const imageWidth = spec.image?.width ?? WING_BLOCK_IMAGE_SIZE; - const imageHeight = spec.image?.height ?? WING_BLOCK_IMAGE_SIZE; + const imageWidth = Number(spec.image?.width ?? WING_BLOCK_IMAGE_SIZE); + const imageHeight = Number(spec.image?.height ?? WING_BLOCK_IMAGE_SIZE); const imageBox = { x: -imageWidth / 2, y: -imageHeight / 2, @@ -182,28 +185,79 @@ const getWingBlockMetrics = (spec: IStorylineSpec, index: number) => { height: imageHeight }; - const onLeft = isTextOnLeft(spec, index); + const direction = getWingDirection(spec); + const count = spec.data?.length ?? 0; + + // 特殊块的垂直布局:文字在 image 下方,水平居中 + // - direction='right' → 最后一个 block + // - direction='left' → 第一个 block + const isSpecialBelow = + (direction === 'right' && count > 0 && index === count - 1) || (direction === 'left' && index === 0); + const isVerticalLayout = isSpecialBelow; + const textWidth = WING_TEXT_BOX_WIDTH; - const textX = onLeft - ? -imageWidth / 2 - WING_TEXT_GAP_FROM_IMAGE - textWidth - : imageWidth / 2 + WING_TEXT_GAP_FROM_IMAGE; - const textY = -textHeight / 2; - const textBox = { x: textX, y: textY, width: textWidth, height: textHeight }; - const contentBox = { - x: textX, - y: textY + titleLineHeight + titleToContentGap, - width: textWidth, - height: contentHeight - }; + let textBox; + let contentBox; + let connectorBox; + let onLeft; + let verticalAlign; // 'below' | 'above' | null + + if (isVerticalLayout) { + // 垂直布局:text 在 image 下方,水平居中 + const textX = -textWidth / 2; + const textY = imageHeight / 2 + WING_TEXT_IMAGE_GAP; + textBox = { x: textX, y: textY, width: textWidth, height: textHeight }; + contentBox = { + x: textX, + y: textY + titleLineHeight + titleToContentGap, + width: textWidth, + height: contentHeight + }; + // 垂直引导线:从 image 底部到 text 顶部 + connectorBox = { + x: -1, + y: imageHeight / 2, + width: 2, + height: WING_TEXT_IMAGE_GAP + }; + onLeft = false; + verticalAlign = 'below'; + } else { + // 水平布局(默认):text 在 image 一侧 + const textOnLeft = direction === 'right'; + const textX = textOnLeft ? -imageWidth / 2 - WING_TEXT_IMAGE_GAP - textWidth : imageWidth / 2 + WING_TEXT_IMAGE_GAP; + const textY = -textHeight / 2; + textBox = { x: textX, y: textY, width: textWidth, height: textHeight }; + contentBox = { + x: textX, + y: textY + titleLineHeight + titleToContentGap, + width: textWidth, + height: contentHeight + }; + // 水平引导线 + const imageEdgeX = textOnLeft ? -imageWidth / 2 : imageWidth / 2; + const textEdgeX = textOnLeft ? textX + textWidth : textX; + connectorBox = { + x: Math.min(imageEdgeX, textEdgeX), + y: 0, + width: Math.abs(textEdgeX - imageEdgeX), + height: 2 + }; + onLeft = textOnLeft; + verticalAlign = null; + } + return { onLeft, + verticalAlign, titleFontSize, titleLineHeight, contentFontSize, contentLineHeight, imageBox, textBox, - contentBox + contentBox, + connectorBox }; }; @@ -215,7 +269,6 @@ export const buildWingBlockMark = ( const hasImage = !!block.image; const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; const themeColor = getThemeColor(spec); - const metrics = getWingBlockMetrics(spec, index); // image 背后的装饰图元(halo)默认不展示 const showBackground = spec.image?.showBackground === true; @@ -229,6 +282,21 @@ export const buildWingBlockMark = ( y: (_d: unknown, ctx: LayoutContext) => getWingBlockCenter(spec, ctx, index).y }, children: [ + // 引导线:连接 image 和 text box + { + type: 'rect', + name: `storyline-block-connector-${index}`, + interactive: false, + zIndex: LayoutZIndex.Mark + 2, + style: { + x: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).connectorBox.x, + y: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).connectorBox.y, + width: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).connectorBox.width, + height: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).connectorBox.height, + fill: themeColor, + opacity: 0.6 + } + } as ICustomMarkSpec<'rect'>, showBackground ? ({ type: 'symbol', @@ -237,7 +305,11 @@ export const buildWingBlockMark = ( style: { x: 0, y: 0, - size: Math.max(metrics.imageBox.width, metrics.imageBox.height) + 12, + size: (_d: unknown, ctx: LayoutContext) => + Math.max( + getWingBlockMetrics(spec, ctx, index).imageBox.width, + getWingBlockMetrics(spec, ctx, index).imageBox.height + ) + 12, symbolType: 'circle', fill: withAlpha(themeColor, 0.18), stroke: themeColor, @@ -252,10 +324,15 @@ export const buildWingBlockMark = ( interactive: false, ...omitImageLayoutSpec(spec.image), style: { - x: metrics.imageBox.x, - y: metrics.imageBox.y, - width: metrics.imageBox.width, - height: metrics.imageBox.height, + x: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).imageBox.x, + y: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).imageBox.y, + width: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).imageBox.width, + height: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).imageBox.height, + cornerRadius: (_d: unknown, ctx: LayoutContext) => + Math.min( + getWingBlockMetrics(spec, ctx, index).imageBox.width, + getWingBlockMetrics(spec, ctx, index).imageBox.height + ) / 2, image: block.image, repeatX: 'no-repeat', repeatY: 'no-repeat', @@ -269,11 +346,15 @@ export const buildWingBlockMark = ( name: `storyline-block-image-bg-${index}`, interactive: false, style: { - x: metrics.imageBox.x, - y: metrics.imageBox.y, - width: metrics.imageBox.width, - height: metrics.imageBox.height, - cornerRadius: Math.min(metrics.imageBox.width, metrics.imageBox.height) / 2, + x: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).imageBox.x, + y: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).imageBox.y, + width: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).imageBox.width, + height: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).imageBox.height, + cornerRadius: (_d: unknown, ctx: LayoutContext) => + Math.min( + getWingBlockMetrics(spec, ctx, index).imageBox.width, + getWingBlockMetrics(spec, ctx, index).imageBox.height + ) / 2, fill: '#ffffff', stroke: themeColor, lineWidth: 2 @@ -287,18 +368,30 @@ export const buildWingBlockMark = ( zIndex: LayoutZIndex.Mark + 10, ...spec.title, style: { - x: metrics.onLeft ? metrics.textBox.x + metrics.textBox.width : metrics.textBox.x, - y: metrics.textBox.y, + x: (_d: unknown, ctx: LayoutContext) => { + const m = getWingBlockMetrics(spec, ctx, index); + if (m.verticalAlign) { + return m.textBox.x + m.textBox.width / 2; + } + return m.onLeft ? m.textBox.x + m.textBox.width : m.textBox.x; + }, + y: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).textBox.y, text: block.title, - maxLineWidth: metrics.textBox.width, - fontSize: metrics.titleFontSize, - lineHeight: metrics.titleLineHeight, + maxLineWidth: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).textBox.width, + fontSize: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).titleFontSize, + lineHeight: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).titleLineHeight, fontWeight: 'bold', fill: themeColor, stroke: '#fff', lineWidth: 5, lineJoin: 'round', - textAlign: metrics.onLeft ? 'right' : 'left', + textAlign: (_d: unknown, ctx: LayoutContext) => { + const m = getWingBlockMetrics(spec, ctx, index); + if (m.verticalAlign) { + return 'center'; + } + return m.onLeft ? 'right' : 'left'; + }, textBaseline: 'top', ...spec.title?.style } @@ -313,16 +406,28 @@ export const buildWingBlockMark = ( ...spec.content, textType: 'rich', style: { - x: metrics.onLeft ? metrics.contentBox.x + metrics.contentBox.width : metrics.contentBox.x, - y: metrics.contentBox.y, - width: metrics.contentBox.width, - height: metrics.contentBox.height, - maxLineWidth: metrics.contentBox.width, - heightLimit: metrics.contentBox.height, + x: (_d: unknown, ctx: LayoutContext) => { + const m = getWingBlockMetrics(spec, ctx, index); + if (m.verticalAlign) { + return m.contentBox.x + m.contentBox.width / 2; + } + return m.onLeft ? m.contentBox.x + m.contentBox.width : m.contentBox.x; + }, + y: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).contentBox.y, + width: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).contentBox.width, + height: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).contentBox.height, + maxLineWidth: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).contentBox.width, + heightLimit: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).contentBox.height, text: buildRichContent(contentText, spec), - fontSize: WING_CONTENT_FONT_SIZE, - lineHeight: WING_CONTENT_LINE_HEIGHT, - textAlign: metrics.onLeft ? 'right' : 'left', + fontSize: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).contentFontSize, + lineHeight: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).contentLineHeight, + textAlign: (_d: unknown, ctx: LayoutContext) => { + const m = getWingBlockMetrics(spec, ctx, index); + if (m.verticalAlign) { + return 'center'; + } + return m.onLeft ? 'right' : 'left'; + }, textBaseline: 'top', wordBreak: 'break-word', ellipsis: '...', diff --git a/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts b/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts index e8c61fc8a1..0b403cb767 100644 --- a/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts +++ b/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts @@ -10,13 +10,17 @@ import { normalizeLayout, resolveBlockWidth, DEFAULT_BLOCK_WIDTH, - DEFAULT_BLOCK_HEIGHT, DEFAULT_IMAGE_GAP } from './layouts/common'; import { buildClockArcMark, buildClockBlockMark, buildClockCenterImageMark } from './layouts/clock'; import { buildDefaultBlockMark, buildDefaultLineMark } from './layouts/default'; import { buildLandscapeBlockMark, buildLandscapeConnectingCurve } from './layouts/landscape'; -import { buildPortraitAxisMark, buildPortraitBlockMark } from './layouts/portrait'; +import { + buildPortraitAxisMark, + buildPortraitBlockMark, + PORTRAIT_CONTENT_HEIGHT_RATIO, + PORTRAIT_IMAGE_HEIGHT_RATIO +} from './layouts/portrait'; import { buildArcBlockMark, buildArcCenterImageMark, buildArcMark } from './layouts/arc'; import { buildWingArcMark, buildWingBlockMark } from './layouts/wing'; import { buildLadderBlockMark, buildLadderDiagonalMark, buildLadderHeadlineMark } from './layouts/ladder'; @@ -44,7 +48,8 @@ export class StorylineChartSpecTransformer extends CommonChartSpecTransformer { const SMALL = 20; // 给 textBox(240px)+ 一定呼吸空间,避免内容超出画布 const TEXT_RESERVE = 280; - // portrait 最后一个 block 下方的 textBox 大约 60-80px,加 image 半高、间距,预留 160px - const PORTRAIT_BOTTOM_RESERVE = 160; const arc = isArc(spec as IStorylineSpec); const arcDown = arc && normalizeLayout((spec as IStorylineSpec).layout).direction === 'down'; const arcUp = arc && !arcDown; const portrait = isPortrait(spec as IStorylineSpec); const ladder = isLadder(spec as IStorylineSpec); + const wing = isWing(spec as IStorylineSpec); + const clock = isClock(spec as IStorylineSpec); + // clock 辐射式布局:底部和顶部 blocks 的文字会向外延伸,需要在四周围留空间 + // portrait 底部 padding:精准预留最后一个 block 的 content 展示空间。 + // portrait 几何(layouts/portrait.ts): + // - 每个 block center.y 等距放置 + // - image 中心 = block center;imageHeight ≈ slotHeight * PORTRAIT_IMAGE_HEIGHT_RATIO + // - content 紧贴 image 下方:textY = image 底 + textGap;textHeight = titleLine + titleGap + contentHeight + // - contentHeight ≈ slotHeight * PORTRAIT_CONTENT_HEIGHT_RATIO + // 最后一个 block center 到 canvas 底部需要至少 imageH/2 + textGap + titleLine + titleGap + contentH。 + // transformSpec 阶段无法获得真实 region,使用 spec.height 估算 slotHeight;缺省回退到 LARGE。 + const portraitBottomReserve = (() => { + if (!portrait) { + return 0; + } + const count = (spec as IStorylineSpec).data?.length ?? 0; + const canvasHeight = (spec as IStorylineSpec).height as number | undefined; + if (!count || !canvasHeight) { + return LARGE; + } + // 在 transformSpec 阶段还没经过 region 减去 padding 等步骤, + // 这里直接用 canvasHeight / (count + 1) 作为 slotHeight 的近似上界, + // 后续 layout 逻辑会基于真实 region 重新计算 imageH / contentH。 + const slotHeight = canvasHeight / (count + 1); + const imageHeight = (spec as IStorylineSpec).image?.height ?? slotHeight * PORTRAIT_IMAGE_HEIGHT_RATIO; + const contentHeight = slotHeight * PORTRAIT_CONTENT_HEIGHT_RATIO; + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 26); + const titleLineHeight = Number((spec.title?.style as any)?.lineHeight ?? Math.round(titleFontSize * 1.35)); + const textGap = 8; // PORTRAIT_TEXT_GAP_FROM_IMAGE + const titleToContentGap = 4; // PORTRAIT_TITLE_TO_CONTENT_GAP + const breath = 16; // 额外呼吸空间 + return Math.max( + LARGE, + Math.round(imageHeight / 2 + textGap + titleLineHeight + titleToContentGap + contentHeight + breath) + ); + })(); // ladder: // - 左右 padding ≈ block content 宽度 × 2(保证两端 block 沿对角线水平有呼吸) // - 上下 padding ≈ block 高度 × 3(保证两端 block 沿对角线垂直留出充足画布留白) - // 由于 transformSpec 阶段还无法获取真实 viewWidth,这里直接用 spec 中的估值 + // 由于 transformSpec 阶段还无法获取真实 viewWidth,这里直接用 spec 中的估值。 + // 同时限制 padding 不超过 canvas 对应维度的 30%,否则 inner 区域会被挤压到不可见。 const ladderHorizontalPadding = (() => { if (!ladder) { return 0; @@ -73,47 +113,58 @@ const applyDefaultPadding = (spec: any) => { const imageGap = (spec as IStorylineSpec).image?.gap ?? DEFAULT_IMAGE_GAP; const innerPadding = 12 * 2; // up-ladder 默认 block padding 12,左右共 24 const contentWidth = Math.max(blockWidth - imageWidth - imageGap - innerPadding, DEFAULT_BLOCK_WIDTH * 0.5); - return Math.round(contentWidth * 2); + const ideal = Math.round(contentWidth * 2); + const canvasWidth = (spec as IStorylineSpec).width as number | undefined; + const cap = canvasWidth ? Math.floor(canvasWidth * 0.3) : ideal; + return Math.min(ideal, cap); })(); const ladderVerticalPadding = (() => { if (!ladder) { return 0; } - const blockHeight = (spec as IStorylineSpec).block?.height ?? DEFAULT_BLOCK_HEIGHT; - const chartHeight = (spec as IStorylineSpec).height; - const heightCap = - typeof chartHeight === 'number' && chartHeight > 0 ? Math.max(SMALL, Math.round(chartHeight * 0.18)) : Infinity; - return Math.round(Math.min(blockHeight * 3, heightCap)); + const blockHeight = (spec as IStorylineSpec).block?.height ?? 132; + const ideal = Math.round(blockHeight * 3); + const canvasHeight = (spec as IStorylineSpec).height as number | undefined; + const cap = canvasHeight ? Math.floor(canvasHeight * 0.3) : ideal; + return Math.min(ideal, cap); })(); - // arc up(dome): 底部贴 centerImage(LARGE),顶部留给 textBox(TEXT_RESERVE) - // arc down(bowl): 顶部贴 centerImage(LARGE),底部留给 textBox(TEXT_RESERVE) - // portrait: 底部留给最后一个 block 的 textBox(PORTRAIT_BOTTOM_RESERVE) + // arc up(dome): 顶部留给 textBox,底部紧贴(不要额外 padding) + // arc down(bowl): 底部留给 textBox,顶部紧贴(不要额外 padding) + // portrait: 底部留给最后一个 block 的 content // ladder: 四周均为 content 宽度 // 其它:保持原默认 [SMALL, SMALL, LARGE, SMALL] - const defaultTop = ladder ? ladderVerticalPadding : arcDown ? LARGE : arcUp ? TEXT_RESERVE : SMALL; - const defaultBottom = ladder - ? ladderVerticalPadding - : arcDown - ? TEXT_RESERVE - : portrait - ? PORTRAIT_BOTTOM_RESERVE - : LARGE; - const defaultLeft = ladder ? ladderHorizontalPadding : SMALL; - const defaultRight = ladder ? ladderHorizontalPadding : SMALL; + const defaultTop = clock ? 40 : ladder ? ladderVerticalPadding : arcDown ? 0 : arcUp ? TEXT_RESERVE : SMALL; + const defaultBottom = clock + ? 60 + : portrait + ? portraitBottomReserve + : wing + ? 300 + : arcUp + ? 0 + : arcDown + ? TEXT_RESERVE + : LARGE; + // arc:左右 padding = content 宽度(canvasWidth / (count + 1)),保证内容沿弧线均匀分布 + const arcHorizontalPadding = (() => { + if (!arc) { + return SMALL; + } + const count = Math.max((spec as IStorylineSpec).data?.length ?? 0, 1); + const canvasWidth = (spec as IStorylineSpec).width as number | undefined; + if (!canvasWidth) { + return SMALL; + } + return Math.round(canvasWidth / (count + 1)); + })(); + const defaultLeft = clock ? 40 : ladder ? ladderHorizontalPadding : arcHorizontalPadding; + const defaultRight = clock ? 40 : ladder ? ladderHorizontalPadding : arcHorizontalPadding; + const p = spec.padding; - if (p === undefined || p === null) { + if (p == null) { spec.padding = [defaultTop, defaultRight, defaultBottom, defaultLeft]; return; } - if (typeof p === 'number') { - spec.padding = [ - Math.max(p, defaultTop), - Math.max(p, defaultRight), - Math.max(p, defaultBottom), - Math.max(p, defaultLeft) - ]; - return; - } if (Array.isArray(p)) { const [t, r = defaultRight, b, l = defaultLeft] = p; spec.padding = [t ?? defaultTop, r, b ?? defaultBottom, l]; From 04cc025bd31e533fbe0653b721b6507e2acb0d32 Mon Sep 17 00:00:00 2001 From: skie1997 Date: Mon, 22 Jun 2026 11:54:17 +0800 Subject: [PATCH 6/8] fix: showbackground problem of different layout --- .../runtime/browser/test-page/storyline.ts | 5 ++- .../src/charts/storyline/interface.ts | 4 +- .../src/charts/storyline/layouts/arc.ts | 5 ++- .../src/charts/storyline/layouts/clock.ts | 18 +++++++++ .../src/charts/storyline/layouts/common.ts | 3 ++ .../src/charts/storyline/layouts/default.ts | 23 +++++++++++- .../src/charts/storyline/layouts/ladder.ts | 21 +++++++++++ .../src/charts/storyline/layouts/landscape.ts | 37 ++++++++++--------- .../src/charts/storyline/layouts/portrait.ts | 3 +- .../src/charts/storyline/layouts/wing.ts | 5 ++- 10 files changed, 98 insertions(+), 26 deletions(-) diff --git a/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts b/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts index 74dce6e8ca..6cea35046d 100644 --- a/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts +++ b/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts @@ -125,7 +125,10 @@ const createLandscapeSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ data: buildData(layout), layout, themeColor, - line: commonLine + line: commonLine, + image: { + showBackground: false + } }); // portrait:默认 block.height = regionHeight / count,imageHeight = blockHeight * 0.4, diff --git a/packages/vchart-extension/src/charts/storyline/interface.ts b/packages/vchart-extension/src/charts/storyline/interface.ts index c9759b640d..90ec309245 100644 --- a/packages/vchart-extension/src/charts/storyline/interface.ts +++ b/packages/vchart-extension/src/charts/storyline/interface.ts @@ -91,8 +91,8 @@ export interface IStorylineImageSpec extends IMarkSpec { gap?: number; /** * 是否展示 image 背后的白色背景 rect(白底 + 主题色描边)。 - * portrait / landscape / wing 等布局支持。 - * 默认 false(不展示)。 + * 所有布局支持。 + * portrait / landscape 默认 false(不展示),其他布局默认 true(展示)。 * 注:不影响 subImage(错位装饰图元的显隐。 */ showBackground?: boolean; diff --git a/packages/vchart-extension/src/charts/storyline/layouts/arc.ts b/packages/vchart-extension/src/charts/storyline/layouts/arc.ts index 0c643cb09a..bb20908abc 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/arc.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/arc.ts @@ -11,6 +11,7 @@ import { normalizeLayout, omitImageLayoutSpec, resolveBlockWidth, + shouldShowImageBackground, withAlpha } from './common'; @@ -467,8 +468,8 @@ export const buildArcBlockMark = ( lineWidth: 2 } } as ICustomMarkSpec<'symbol'>), - // 圆形 image 的外层装饰环(默认不展示,仅当 spec.image.showBackground === true 时渲染) - spec.image?.showBackground === true + // 圆形 image 的外层装饰环 + shouldShowImageBackground(spec) ? ({ type: 'symbol', name: `storyline-block-image-halo-${index}`, diff --git a/packages/vchart-extension/src/charts/storyline/layouts/clock.ts b/packages/vchart-extension/src/charts/storyline/layouts/clock.ts index 25a75c6d28..2db04960a7 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/clock.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/clock.ts @@ -8,6 +8,7 @@ import { getRegionGeometry, getThemeColor, normalizePadding, + shouldShowImageBackground, withAlpha } from './common'; @@ -289,6 +290,7 @@ export const buildClockBlockMark = ( ): IExtensionGroupMarkSpec => { const hasImage = !!block.image; const themeColor = getThemeColor(spec); + const showImageBackground = shouldShowImageBackground(spec); const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; const leadPath = (_d: unknown, ctx: LayoutContext) => { @@ -311,6 +313,22 @@ export const buildClockBlockMark = ( fillOpacity: 0 } } as ICustomMarkSpec<'path'>, + showImageBackground + ? ({ + type: 'symbol', + name: `storyline-clock-dot-bg-${index}`, + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).x, + y: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).y, + size: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).diameter + 10, + symbolType: 'circle', + fill: '#ffffff', + stroke: themeColor, + lineWidth: 2 + } + } as ICustomMarkSpec<'symbol'>) + : null, // 圆形小图(dot):压在轨道上,作为时间锚点 hasImage ? ({ diff --git a/packages/vchart-extension/src/charts/storyline/layouts/common.ts b/packages/vchart-extension/src/charts/storyline/layouts/common.ts index e5482c2cd5..9f36d42cbd 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/common.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/common.ts @@ -43,6 +43,9 @@ export const isLadder = (spec: IStorylineSpec) => normalizeLayout(spec.layout).t export const getThemeColor = (spec: IStorylineSpec) => spec.themeColor ?? DEFAULT_THEME_COLOR; +export const shouldShowImageBackground = (spec: IStorylineSpec) => + spec.image?.showBackground ?? !(isPortrait(spec) || isLandscape(spec)); + // ===== 颜色工具 ===== /** diff --git a/packages/vchart-extension/src/charts/storyline/layouts/default.ts b/packages/vchart-extension/src/charts/storyline/layouts/default.ts index ce18a3fcee..0c4a0462e2 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/default.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/default.ts @@ -13,9 +13,11 @@ import { getImageBox, getLayout, getTextBox, + getThemeColor, normalizePadding, omitImageLayoutSpec, - resolveBlockWidth + resolveBlockWidth, + shouldShowImageBackground } from './common'; /** @@ -115,6 +117,7 @@ export const buildDefaultBlockMark = ( const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 18); const titleLineHeight = Number((spec.title?.style as any)?.lineHeight ?? Math.round(titleFontSize * 1.35)); + const themeColor = getThemeColor(spec); return { type: 'group' as any, @@ -148,6 +151,24 @@ export const buildDefaultBlockMark = ( } } as ICustomMarkSpec<'rect'>) : null, + shouldShowImageBackground(spec) + ? ({ + type: 'rect', + name: `storyline-block-image-bg-${index}`, + interactive: false, + style: { + x: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).imageBox.x, + y: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).imageBox.y, + width: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).imageBox.width, + height: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).imageBox.height, + cornerRadius: 8, + fill: '#ffffff', + stroke: themeColor, + lineWidth: 2, + ...spec.block?.style + } + } as ICustomMarkSpec<'rect'>) + : null, hasImage ? ({ type: 'image', diff --git a/packages/vchart-extension/src/charts/storyline/layouts/ladder.ts b/packages/vchart-extension/src/charts/storyline/layouts/ladder.ts index f5a88fee36..3c7b20dd56 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/ladder.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/ladder.ts @@ -15,6 +15,7 @@ import { normalizePadding, omitImageLayoutSpec, resolveBlockWidth, + shouldShowImageBackground, withAlpha } from './common'; @@ -256,6 +257,8 @@ export const buildLadderBlockMark = ( Math.round(titleFontSize * (LADDER_TITLE_LINE_HEIGHT / LADDER_TITLE_FONT_SIZE)) ); const showBackground = spec.block?.showBackground === true; + const showImageBackground = shouldShowImageBackground(spec); + const themeColor = getThemeColor(spec); // textAlign='right' 时 x 锚点取 textBox 右端,否则取左端 const getTitleX = (ctx: LayoutContext) => { @@ -295,6 +298,24 @@ export const buildLadderBlockMark = ( } } as ICustomMarkSpec<'rect'>) : null, + showImageBackground + ? ({ + type: 'rect', + name: `storyline-block-image-bg-${index}`, + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).imageBox.x, + y: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).imageBox.y, + width: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).imageBox.width, + height: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).imageBox.height, + cornerRadius: 8, + fill: '#ffffff', + stroke: themeColor, + lineWidth: 2, + ...spec.block?.style + } + } as ICustomMarkSpec<'rect'>) + : null, hasImage ? ({ type: 'image', diff --git a/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts b/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts index bb9fdd6823..74fa951022 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts @@ -12,7 +12,8 @@ import { getThemeColor, normalizePadding, omitImageLayoutSpec, - resolveBlockWidth + resolveBlockWidth, + shouldShowImageBackground } from './common'; // landscape 布局下,image rect 与 text rect 分离展示 @@ -242,22 +243,24 @@ export const buildLandscapeBlockMark = ( height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).groupHeight }, children: [ - { - type: 'rect', - name: `storyline-block-image-bg-${index}`, - interactive: false, - style: { - x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.x, - y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.y, - width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.width, - height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.height, - cornerRadius: 8, - fill: '#ffffff', - stroke: themeColor, - lineWidth: 2, - ...blockStyle - } - } as ICustomMarkSpec<'rect'>, + shouldShowImageBackground(spec) + ? ({ + type: 'rect', + name: `storyline-block-image-bg-${index}`, + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.y, + width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.width, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.height, + cornerRadius: 8, + fill: '#ffffff', + stroke: themeColor, + lineWidth: 2, + ...blockStyle + } + } as ICustomMarkSpec<'rect'>) + : null, hasImage ? ({ type: 'image', diff --git a/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts b/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts index 50eeb4a3f0..68e00826da 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts @@ -10,6 +10,7 @@ import { getThemeColor, omitImageLayoutSpec, resolveBlockWidth, + shouldShowImageBackground, withAlpha } from './common'; @@ -280,7 +281,7 @@ export const buildPortraitBlockMark = ( } } as ICustomMarkSpec<'image'>) : null, - spec.image?.showBackground === true + shouldShowImageBackground(spec) ? ({ type: 'rect', name: `storyline-block-image-bg-${index}`, diff --git a/packages/vchart-extension/src/charts/storyline/layouts/wing.ts b/packages/vchart-extension/src/charts/storyline/layouts/wing.ts index 55af8dac8e..31d75479c0 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/wing.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/wing.ts @@ -10,6 +10,7 @@ import { getThemeColor, normalizeLayout, omitImageLayoutSpec, + shouldShowImageBackground, withAlpha } from './common'; @@ -269,8 +270,8 @@ export const buildWingBlockMark = ( const hasImage = !!block.image; const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; const themeColor = getThemeColor(spec); - // image 背后的装饰图元(halo)默认不展示 - const showBackground = spec.image?.showBackground === true; + // image 背后的装饰图元(halo) + const showBackground = shouldShowImageBackground(spec); return { type: 'group' as any, From d05ce0ec0d6897a72c3b77b02f212a1ca0407324 Mon Sep 17 00:00:00 2001 From: skie1997 Date: Tue, 23 Jun 2026 18:54:47 +0800 Subject: [PATCH 7/8] feat: enhance title image and some detail --- .../runtime/browser/test-page/storyline.ts | 35 ++- .../src/charts/storyline/interface.ts | 157 +++++++++++- .../src/charts/storyline/layouts/arc.ts | 221 ++++++----------- .../src/charts/storyline/layouts/clock.ts | 59 +++-- .../src/charts/storyline/layouts/common.ts | 232 +++++++++++++++++- .../src/charts/storyline/layouts/default.ts | 24 +- .../src/charts/storyline/layouts/ladder.ts | 44 ++-- .../src/charts/storyline/layouts/landscape.ts | 33 ++- .../src/charts/storyline/layouts/portrait.ts | 138 ++++++----- .../src/charts/storyline/layouts/wing.ts | 90 ++++++- .../charts/storyline/storyline-transformer.ts | 69 ++++-- 11 files changed, 767 insertions(+), 335 deletions(-) diff --git a/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts b/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts index 6cea35046d..2bc5f72c47 100644 --- a/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts +++ b/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts @@ -5,6 +5,7 @@ import type { IStorylineSpec, StorylineLayoutType } from '../../../../src/charts const layouts: StorylineLayoutType[] = ['landscape', 'portrait', 'ladder', 'spiral', 'clock', 'arc', 'wing']; const SUB_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-2022.png'; +const TITLE_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/title-world-cap.png'; const baseData = [ { @@ -112,6 +113,9 @@ const commonLine: IStorylineSpec['line'] = { }; const themeColor = 'rgb(228,154,56)'; +const titleImage: IStorylineSpec['titleImage'] = { + image: TITLE_IMAGE_URL +}; const WIDTH = 1920; const HEIGHT = 1080; @@ -124,6 +128,7 @@ const createLandscapeSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ height: HEIGHT, data: buildData(layout), layout, + titleImage, themeColor, line: commonLine, image: { @@ -139,6 +144,7 @@ const createPortraitSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ width: HEIGHT, data: buildData(layout), layout, + titleImage, themeColor }); @@ -149,20 +155,19 @@ const createArcSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ width: WIDTH, height: HEIGHT, data: buildData(layout), - layout: { type: 'arc', direction: 'up' }, - themeColor, - centerImage: { - image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' - } + layout: { type: 'arc', direction: 'down' }, + titleImage, + themeColor }); -// clock:环绕式时间线,需要 centerImage 作为盘心 +// clock:环绕式时间线 const createClockSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ type: 'storyline', height: HEIGHT, width: WIDTH, padding: [20, 20, 50, 20], layout: 'clock', + titleImage, themeColor, // background: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', data: [ @@ -217,17 +222,7 @@ const createClockSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ '点球大战中阿根廷4比2取胜。', image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' } - ], - centerImage: { - image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' - // width: 300, - // height: 300, - // style: { - // width: 300, - // height: 300, - // cornerRadius: 150 - // } - } + ] }); const createDefaultSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ @@ -235,6 +230,7 @@ const createDefaultSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ padding: 20, data: buildData(layout), layout, + titleImage, themeColor, block: { widthRatio: 0.28, @@ -258,7 +254,8 @@ const createWingSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ height: WIDTH, width: HEIGHT, data: buildData(layout), - layout: { type: 'wing', direction: 'right' }, + layout: { type: 'wing', direction: 'left' }, + titleImage, themeColor }); @@ -269,7 +266,7 @@ const createLadderSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ width: 1600, height: 900, padding: 20, - layout: { type: 'ladder', direction: 'up', headline: 'ladder' }, + layout: { type: 'ladder', direction: 'down', headline: 'ladder' }, themeColor: '#C8102E', background: 'transparent', data: [ diff --git a/packages/vchart-extension/src/charts/storyline/interface.ts b/packages/vchart-extension/src/charts/storyline/interface.ts index 90ec309245..06b2faa9d3 100644 --- a/packages/vchart-extension/src/charts/storyline/interface.ts +++ b/packages/vchart-extension/src/charts/storyline/interface.ts @@ -9,18 +9,51 @@ import type { StringOrNumber } from '@visactor/vchart'; +/** + * Storyline 图表支持的布局类型。 + */ export type StorylineLayoutType = 'clock' | 'arc' | 'wing' | 'landscape' | 'portrait' | 'ladder' | 'spiral'; +/** + * block 内图片相对文本内容的摆放位置。 + */ export type StorylineImagePosition = 'top' | 'left' | 'right' | 'bottom'; +/** + * block 之间连线的绘制方式。 + */ export type StorylineLineType = 'line' | 'polyline' | 'curve'; +/** + * wing 布局的展开方向。 + */ export type StorylineWingDirection = 'left' | 'right'; +/** + * ladder 布局的对角线方向。 + */ export type StorylineLadderDirection = 'up' | 'down'; +/** + * arc 布局的弧形方向。 + */ export type StorylineArcDirection = 'up' | 'down'; +/** + * Storyline 中单个叙事节点的数据配置。 + */ export interface IStorylineBlock { + /** + * 节点唯一标识。未配置时使用数据索引作为内部标识。 + */ id?: StringOrNumber; + /** + * 节点标题文本。 + */ title?: string; + /** + * 节点正文内容。传入数组时会按段落拼接为富文本内容。 + */ content?: string | string[]; + /** + * 节点主图片,支持图片 URL、HTMLImageElement 或 HTMLCanvasElement。 + */ image?: string | HTMLImageElement | HTMLCanvasElement; /** * 绘制在主 image 背后的装饰图(如 portrait 布局的错位 shadow image)。 @@ -33,10 +66,19 @@ export interface IStorylineBlock { * 配合 spec.marker 控制样式与显隐。 */ marker?: string; + /** + * 绑定到该节点的原始业务数据,供外部交互或扩展逻辑使用。 + */ datum?: unknown; } +/** + * Storyline 布局参数配置。 + */ export interface IStorylineLayoutOptions { + /** + * 布局类型。 + */ type: StorylineLayoutType; /** * 边缘留白,支持单值或 [top, right, bottom, left]。 @@ -58,7 +100,7 @@ export interface IStorylineLayoutOptions { * 方向控制: * - wing 布局:'left' | 'right',圆心锚位置; * - ladder 布局:'up' | 'down','up' 表示左下→右上对角线(默认),'down' 表示左上→右下对角线; - * - arc 布局:'up' | 'down','up' 表示穹顶(centerImage 贴底,弧线在上方),'down' 表示碗形(centerImage 贴顶,弧线在下方)。 + * - arc 布局:'up' | 'down','up' 表示穹顶(titleImage 贴底,弧线在上方),'down' 表示碗形(titleImage 贴顶,弧线在下方)。 */ direction?: StorylineWingDirection | StorylineLadderDirection | StorylineArcDirection; /** @@ -68,47 +110,119 @@ export interface IStorylineLayoutOptions { headline?: string; } +/** + * Storyline 节点 block 的尺寸、间距、背景和样式配置。 + */ export interface IStorylineBlockSpec { + /** + * block 的固定宽度。配置后优先级高于 widthRatio、minWidth、maxWidth。 + */ width?: number; + /** + * block 宽度相对可用视图宽度的比例。 + */ widthRatio?: number; + /** + * block 自适应宽度的最小值。 + */ minWidth?: number; + /** + * block 自适应宽度的最大值。 + */ maxWidth?: number; + /** + * block 的固定高度。 + */ height?: number; + /** + * block 内边距,支持单值或 [top, right, bottom, left]。 + */ padding?: number | [number, number, number, number]; + /** + * block 之间的布局间距。 + */ gap?: number; /** * 是否展示 block 背后的卡片背景 rect(白底 + 描边 + 阴影)。 - * 仅 up-ladder 等少数布局支持,默认 false(不展示)。 + * 仅 ladder 等少数布局支持,默认 false(不展示)。 */ showBackground?: boolean; + /** + * block 背景 rect 的图形样式配置。 + */ style?: Partial; } +/** + * Storyline 节点主图片配置。 + */ export interface IStorylineImageSpec extends IMarkSpec { + /** + * 节点主图片宽度。 + */ width?: number; + /** + * 节点主图片高度。 + */ height?: number; + /** + * 节点主图片相对标题和正文的位置。 + */ position?: StorylineImagePosition; + /** + * 节点主图片与文本内容之间的间距。 + */ gap?: number; /** * 是否展示 image 背后的白色背景 rect(白底 + 主题色描边)。 * 所有布局支持。 * portrait / landscape 默认 false(不展示),其他布局默认 true(展示)。 - * 注:不影响 subImage(错位装饰图元的显隐。 + * 注:不影响 subImage(错位装饰图元)的显隐。 */ showBackground?: boolean; } -export interface IStorylineCenterImageSpec extends IMarkSpec { +/** + * Storyline 标题图片配置,用于在不同布局中放置主题图片或标题图。 + */ +export interface IStorylineTitleImageSpec extends IMarkSpec { + /** + * 标题图片宽度。未配置时由布局根据画布尺寸自适应计算。 + */ width?: number; + /** + * 标题图片高度。未配置时由布局根据宽度和默认比例自适应计算。 + */ height?: number; + /** + * 是否显示标题图片。 + */ visible?: boolean; + /** + * 标题图片资源,支持图片 URL、HTMLImageElement 或 HTMLCanvasElement。 + */ image?: string | HTMLImageElement | HTMLCanvasElement; } +/** + * Storyline 连接线配置。 + */ export interface IStorylineLineSpec extends IMarkSpec { + /** + * 是否显示连接线或布局主轴线。 + */ visible?: boolean; + /** + * 连接线类型。 + */ type?: StorylineLineType; + /** + * 是否在线段末端显示箭头。 + */ showArrow?: boolean; + /** + * 箭头尺寸。 + */ arrowSize?: number; /** * 连接线和 block 边缘之间的距离。 @@ -116,20 +230,53 @@ export interface IStorylineLineSpec extends IMarkSpec { distance?: number; } +/** + * Storyline 图表总配置。 + */ export interface IStorylineSpec extends Omit { + /** + * 图表类型,固定为 'storyline'。 + */ type: 'storyline'; + /** + * Storyline 节点数据数组。 + */ data: IStorylineBlock[]; + /** + * 布局类型或布局参数配置。 + */ layout?: StorylineLayoutType | IStorylineLayoutOptions; + /** + * 节点 block 的尺寸、间距、背景和样式配置。 + */ block?: IStorylineBlockSpec; + /** + * 节点标题文本 mark 配置。 + */ title?: IMarkSpec; + /** + * 图表标题图片配置。 + */ + titleImage?: IStorylineTitleImageSpec; + /** + * 节点正文富文本 mark 配置。 + */ content?: IMarkSpec; + /** + * 节点主图片配置。 + */ image?: IStorylineImageSpec; - centerImage?: IStorylineCenterImageSpec; + /** + * 节点之间连接线或布局主轴线配置。 + */ line?: IStorylineLineSpec; /** * 时间节点文本配置(仅 portrait 布局生效)。 * 当 spec.data[i].marker 有值时,在中轴 rect 上沿垂直方向绘制每个 block 的时间节点文本。 */ marker?: IMarkSpec; + /** + * Storyline 主题色,用于连接线、轴、图片背景和强调元素的默认颜色。 + */ themeColor?: string; } diff --git a/packages/vchart-extension/src/charts/storyline/layouts/arc.ts b/packages/vchart-extension/src/charts/storyline/layouts/arc.ts index bb20908abc..6373042a5b 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/arc.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/arc.ts @@ -6,18 +6,21 @@ import { type LayoutContext, type StorylinePoint, buildRichContent, + getImageBackgroundStyle, getRegionGeometry, getThemeColor, normalizeLayout, omitImageLayoutSpec, + resolveAdaptiveLineHeight, resolveBlockWidth, + resolveTitleFontSize, shouldShowImageBackground, withAlpha } from './common'; -// arc 布局:弧形排列 + centerImage(穹顶 / 碗形二合一) -// - direction = 'up'(默认):穹顶 —— centerImage 贴底,弧线在 centerImage 上方 -// - direction = 'down':碗形 —— centerImage 贴顶,弧线在 centerImage 下方 +// arc 布局:弧形排列 + titleImage(穹顶 / 碗形二合一) +// - direction = 'up'(默认):穹顶 —— titleImage 贴底,弧线在 titleImage 上方 +// - direction = 'down':碗形 —— titleImage 贴顶,弧线在 titleImage 下方 // image 默认为圆形,ARC_BLOCK_IMAGE_SIZE 即圆的直径 const ARC_BLOCK_IMAGE_SIZE = 240; // 圆形 image 的边框环厚度(圆形描边宽度) @@ -36,30 +39,27 @@ const ARC_TITLE_TO_CONTENT_GAP = 4; const ARC_TEXT_PADDING = 20; // title/content 区域的最小宽度,确保文字有足够展示空间,不受 image 宽度限制 const ARC_TEXT_BOX_MIN_WIDTH = 240; -// centerImage 边长相对 inner 短边的比例(强制正方形,避免 cover 模式裁切图片) -const ARC_CENTER_IMAGE_SIZE_RATIO = 0.55; -// centerImage 相对 centerSymbol 的比例:image 直径 = symbol 直径 * 这个值(0.8 让 image 略小于 symbol,露出环形背景) -const ARC_CENTER_IMAGE_TO_SYMBOL_RATIO = 0.8; -// 弧线最高/最低点距离 centerImage 顶部/底部的距离 -const ARC_GAP_FROM_CENTER_IMAGE = 200; +// titleImage 默认尺寸 +const ARC_TITLE_IMAGE_WIDTH_RATIO = 0.68; +const ARC_TITLE_IMAGE_MAX_WIDTH = 900; +const ARC_TITLE_IMAGE_HEIGHT_RATIO = 0.34; +// 弧线最高/最低点距离 titleImage 顶部/底部的距离 +const ARC_GAP_FROM_TITLE_IMAGE = 200; const isDownArc = (spec: IStorylineSpec) => normalizeLayout(spec.layout).direction === 'down'; /** - * 计算 arc 布局 centerImage 的 box:水平居中。 + * 计算 arc 布局 titleImage 的 box:水平居中。 * - up(dome):垂直贴底(位于 inner 区域底部) * - down(bowl):垂直贴顶(位于 inner 区域顶部) */ -const getArcCenterImageRect = (spec: IStorylineSpec, ctx: LayoutContext) => { +const getArcTitleImageRect = (spec: IStorylineSpec, ctx: LayoutContext) => { const { width, height, startX, startY } = getRegionGeometry(ctx); - // width/height 已经是 VChart 减去 spec.padding 后的 region 大小 - // 不要再重复减去 padding,直接用 region 几何信息定位 const innerWidth = Math.max(width, 1); const innerHeight = Math.max(height, 1); - // 取 inner 短边作为基准,使 rect 始终为正方形 - const baseSize = Math.min(innerWidth, innerHeight) * ARC_CENTER_IMAGE_SIZE_RATIO; - const w = Math.max(spec.centerImage?.width ?? baseSize, 80); - const h = Math.max(spec.centerImage?.height ?? baseSize, 80); + const baseWidth = Math.min(innerWidth * ARC_TITLE_IMAGE_WIDTH_RATIO, ARC_TITLE_IMAGE_MAX_WIDTH); + const w = Math.max(spec.titleImage?.width ?? baseWidth, 80); + const h = Math.max(spec.titleImage?.height ?? w * ARC_TITLE_IMAGE_HEIGHT_RATIO, 40); const cx = startX + innerWidth / 2; const isDown = isDownArc(spec); const top = isDown ? startY : startY + innerHeight - h; @@ -69,20 +69,20 @@ const getArcCenterImageRect = (spec: IStorylineSpec, ctx: LayoutContext) => { /** * 计算 arc 弧线的几何参数: * - cx / rx / startAngle / endAngle 与 layout.ts 中 arcCenters 一致; - * - cy 与 ry 由两条对齐约束反推,使弧线起/终点 y 与 centerImage 端面对齐, - * 弧线极值点(顶点 / 底点)距离 centerImage 远端 ARC_GAP_FROM_CENTER_IMAGE。 + * - cy 与 ry 由两条对齐约束反推,使弧线起/终点 y 与 titleImage 端面对齐, + * 弧线极值点(顶点 / 底点)距离 titleImage 远端 ARC_GAP_FROM_TITLE_IMAGE。 * - * up(dome):startAngle = 200°、endAngle = 340°(弧线在 centerImage 上方) - * cy + ry * sin(startAngle) = centerImageBottom - * cy - ry = centerImageTop - GAP - * → ry = (centerImageHeight + GAP) / (1 + sin(startAngle)) - * cy = centerImageBottom - ry * sin(startAngle) + * up(dome):startAngle = 200°、endAngle = 340°(弧线在 titleImage 上方) + * cy + ry * sin(startAngle) = titleImageBottom + * cy - ry = titleImageTop - GAP + * → ry = (titleImageHeight + GAP) / (1 + sin(startAngle)) + * cy = titleImageBottom - ry * sin(startAngle) * - * down(bowl):startAngle = 20°、endAngle = 160°(弧线在 centerImage 下方) - * cy + ry * sin(startAngle) = centerImageTop - * cy + ry = centerImageBottom + GAP - * → ry = (GAP + centerImageHeight) / (1 - sin(startAngle)) - * cy = centerImageTop - ry * sin(startAngle) + * down(bowl):startAngle = 20°、endAngle = 160°(弧线在 titleImage 下方) + * cy + ry * sin(startAngle) = titleImageTop + * cy + ry = titleImageBottom + GAP + * → ry = (GAP + titleImageHeight) / (1 - sin(startAngle)) + * cy = titleImageTop - ry * sin(startAngle) */ const getArcGeometry = (spec: IStorylineSpec, ctx: LayoutContext) => { const { width, startX } = getRegionGeometry(ctx); @@ -96,21 +96,21 @@ const getArcGeometry = (spec: IStorylineSpec, ctx: LayoutContext) => { const endAngle = layoutOpt.endAngle ?? (isDown ? 160 : 340); const ratio = layoutOpt.radiusRatio ?? 0.88; const rx = Math.max((innerWidth - blockWidth) / 2, 1) * ratio; - const centerRect = getArcCenterImageRect(spec, ctx); - const centerTop = centerRect.y; - const centerBottom = centerRect.y + centerRect.height; + const titleImageRect = getArcTitleImageRect(spec, ctx); + const centerTop = titleImageRect.y; + const centerBottom = titleImageRect.y + titleImageRect.height; const sinStart = Math.sin((startAngle / 180) * Math.PI); let cy: number; let ry: number; if (isDown) { // bowl:sinStart 接近 1 时 ry → ∞;这里限制下界以防 startAngle 配置异常 const denom = Math.max(1 - sinStart, 0.05); - ry = (centerRect.height + ARC_GAP_FROM_CENTER_IMAGE) / denom; + ry = (titleImageRect.height + ARC_GAP_FROM_TITLE_IMAGE) / denom; cy = centerTop - ry * sinStart; } else { // dome:sinStart 接近 -1 时 ry → ∞ const denom = Math.max(1 + sinStart, 0.05); - ry = (centerRect.height + ARC_GAP_FROM_CENTER_IMAGE) / denom; + ry = (titleImageRect.height + ARC_GAP_FROM_TITLE_IMAGE) / denom; cy = centerBottom - ry * sinStart; } return { @@ -196,138 +196,49 @@ export const buildArcMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | nu }; }; -export const buildArcCenterImageMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { - const visible = spec.centerImage?.visible !== false; - if (!visible) { +export const buildArcTitleImageMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + if (!spec.titleImage?.image || spec.titleImage.visible === false) { return null; } - const themeColor = getThemeColor(spec); - const hasImage = !!spec.centerImage?.image; - // 主题色生成的线性渐变(顶部偏淡 → 底部主题色),作为 centerImage 位置的 symbol 填充 - const symbolGradient = { - gradient: 'linear', - x0: 0.5, - y0: 0, - x1: 0.5, - y1: 1, - stops: [ - { offset: 0, color: withAlpha(themeColor, 0.15) }, - { offset: 1, color: themeColor } - ] - }; return { type: 'group' as any, - name: 'storyline-arc-center', - zIndex: LayoutZIndex.Mark, + name: 'storyline-arc-title-image', + zIndex: LayoutZIndex.Mark + 6, children: [ - // centerImage 底层:圆形 symbol(直径 = imageRect 的边长,保证视觉上是真正的圆) - // - 有图时:浅色渐变底,仅作细微的底色衬托 - // - 无图时:纯白色圆形占位,保持视觉上的圆形轮廓 { - type: 'symbol', - name: 'storyline-arc-center-symbol', + type: 'image', + name: 'storyline-arc-title-image-node', interactive: false, + ...spec.titleImage, style: { x: (_d: unknown, ctx: LayoutContext) => { - const r = getArcCenterImageRect(spec, ctx); - return r.x + r.width / 2; + return getArcTitleImageRect(spec, ctx).x; }, y: (_d: unknown, ctx: LayoutContext) => { - const r = getArcCenterImageRect(spec, ctx); - return r.y + r.height / 2; + return getArcTitleImageRect(spec, ctx).y; }, - size: (_d: unknown, ctx: LayoutContext) => { - const r = getArcCenterImageRect(spec, ctx); - return Math.max(r.width, r.height); + width: (_d: unknown, ctx: LayoutContext) => { + return getArcTitleImageRect(spec, ctx).width; }, - symbolType: 'circle', - fill: hasImage ? symbolGradient : '#ffffff', - stroke: themeColor, - lineWidth: 2 + height: (_d: unknown, ctx: LayoutContext) => { + return getArcTitleImageRect(spec, ctx).height; + }, + image: spec.titleImage.image, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'contain', + imagePosition: 'center', + ...spec.titleImage.style } - } as ICustomMarkSpec<'symbol'>, - hasImage - ? ({ - type: 'image', - name: 'storyline-arc-center-image', - interactive: false, - ...spec.centerImage, - style: { - x: (_d: unknown, ctx: LayoutContext) => { - const r = getArcCenterImageRect(spec, ctx); - const userWidth = (spec.centerImage?.style as { width?: number } | undefined)?.width; - const w = - typeof userWidth === 'number' - ? userWidth - : Math.max(r.width, r.height) * ARC_CENTER_IMAGE_TO_SYMBOL_RATIO; - return r.x + (r.width - w) / 2; - }, - y: (_d: unknown, ctx: LayoutContext) => { - const r = getArcCenterImageRect(spec, ctx); - const userHeight = (spec.centerImage?.style as { height?: number } | undefined)?.height; - const h = - typeof userHeight === 'number' - ? userHeight - : Math.max(r.width, r.height) * ARC_CENTER_IMAGE_TO_SYMBOL_RATIO; - return r.y + (r.height - h) / 2; - }, - width: (_d: unknown, ctx: LayoutContext) => { - const r = getArcCenterImageRect(spec, ctx); - const userWidth = (spec.centerImage?.style as { width?: number } | undefined)?.width; - return typeof userWidth === 'number' - ? userWidth - : Math.max(r.width, r.height) * ARC_CENTER_IMAGE_TO_SYMBOL_RATIO; - }, - height: (_d: unknown, ctx: LayoutContext) => { - const r = getArcCenterImageRect(spec, ctx); - const userHeight = (spec.centerImage?.style as { height?: number } | undefined)?.height; - return typeof userHeight === 'number' - ? userHeight - : Math.max(r.width, r.height) * ARC_CENTER_IMAGE_TO_SYMBOL_RATIO; - }, - cornerRadius: (_d: unknown, ctx: LayoutContext) => { - const r = getArcCenterImageRect(spec, ctx); - const userWidth = (spec.centerImage?.style as { width?: number } | undefined)?.width; - const userHeight = (spec.centerImage?.style as { height?: number } | undefined)?.height; - const w = - typeof userWidth === 'number' - ? userWidth - : Math.max(r.width, r.height) * ARC_CENTER_IMAGE_TO_SYMBOL_RATIO; - const h = - typeof userHeight === 'number' - ? userHeight - : Math.max(r.width, r.height) * ARC_CENTER_IMAGE_TO_SYMBOL_RATIO; - return Math.max(w, h) / 2; - }, - image: spec.centerImage?.image, - repeatX: 'no-repeat', - repeatY: 'no-repeat', - imageMode: 'cover', - imagePosition: 'center', - // 默认锚点设为 image 中心,让 scaleX/scaleY 从中心缩放 - anchor: (_d: unknown, ctx: LayoutContext) => { - const r = getArcCenterImageRect(spec, ctx); - return [r.x + r.width / 2, r.y + r.height / 2]; - }, - ...spec.centerImage?.style - } - } as ICustomMarkSpec<'image'>) - : null - ].filter(Boolean) as ICustomMarkSpec[] + } as ICustomMarkSpec<'image'> + ] }; }; const getArcBlockMetrics = (spec: IStorylineSpec, index: number = 0) => { - const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? ARC_TITLE_FONT_SIZE); - const titleLineHeight = Number( - (spec.title?.style as any)?.lineHeight ?? Math.max(ARC_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.35)) - ); const contentFontSize = Number((spec.content?.style as any)?.fontSize ?? ARC_CONTENT_FONT_SIZE); const contentLineHeight = Number((spec.content?.style as any)?.lineHeight ?? ARC_CONTENT_LINE_HEIGHT); const titleToContentGap = ARC_TITLE_TO_CONTENT_GAP; - // text 区域总高度固定为 ARC_TEXT_BOX_HEIGHT,content 占除 title 与间距外的全部高度 - const textHeight = ARC_TEXT_BOX_HEIGHT; - const contentHeight = Math.max(textHeight - titleLineHeight - titleToContentGap, contentLineHeight); // 强制 image 为正方形(直径),保证圆形裁切有效 const imageDiameter = Math.max(spec.image?.width ?? ARC_BLOCK_IMAGE_SIZE, spec.image?.height ?? ARC_BLOCK_IMAGE_SIZE); @@ -345,6 +256,18 @@ const getArcBlockMetrics = (spec: IStorylineSpec, index: number = 0) => { Number((spec.block as { width?: number } | undefined)?.width ?? defaultTextWidth), ARC_TEXT_BOX_MIN_WIDTH ); + const titleFontSize = resolveTitleFontSize( + spec, + undefined, + spec.data?.[index]?.title, + textBoxWidth, + ARC_TITLE_FONT_SIZE, + [8, 40] + ); + const titleLineHeight = resolveAdaptiveLineHeight(titleFontSize, spec.title?.style as any, ARC_TITLE_LINE_HEIGHT); + // text 区域总高度固定为 ARC_TEXT_BOX_HEIGHT,content 占除 title 与间距外的全部高度 + const textHeight = ARC_TEXT_BOX_HEIGHT; + const contentHeight = Math.max(textHeight - titleLineHeight - titleToContentGap, contentLineHeight); // 前 1/2 为左侧(奇数 count 时中间块也算左侧),右侧为后 1/2; // 左侧 title/content 右对齐(贴引导线),右侧 title/content 左对齐(贴引导线) @@ -448,7 +371,7 @@ export const buildArcBlockMark = ( image: block.image, repeatX: 'no-repeat', repeatY: 'no-repeat', - imageMode: 'cover', + imageMode: 'contain', imagePosition: 'center', ...spec.image?.style } @@ -463,9 +386,7 @@ export const buildArcBlockMark = ( y: 0, size: Math.min(metrics.imageBox.width, metrics.imageBox.height), symbolType: 'circle', - fill: '#ffffff', - stroke: themeColor, - lineWidth: 2 + ...getImageBackgroundStyle(spec) } } as ICustomMarkSpec<'symbol'>), // 圆形 image 的外层装饰环 @@ -481,7 +402,7 @@ export const buildArcBlockMark = ( size: Math.min(metrics.imageBox.width, metrics.imageBox.height) + ARC_BLOCK_IMAGE_HALO_PADDING * 2, symbolType: 'circle', fill: 'transparent', - stroke: themeColor, + stroke: withAlpha(themeColor, 0.82), lineWidth: ARC_BLOCK_IMAGE_BORDER } } as ICustomMarkSpec<'symbol'>) diff --git a/packages/vchart-extension/src/charts/storyline/layouts/clock.ts b/packages/vchart-extension/src/charts/storyline/layouts/clock.ts index 2db04960a7..a4a6f81dbe 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/clock.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/clock.ts @@ -5,9 +5,13 @@ import { type ICustomMarkSpec, type LayoutContext, buildRichContent, + getImageBackgroundStyle, getRegionGeometry, getThemeColor, normalizePadding, + omitImageLayoutSpec, + resolveAdaptiveLineHeight, + resolveTitleFontSize, shouldShowImageBackground, withAlpha } from './common'; @@ -21,20 +25,20 @@ import { * │ │ * ●●● │ ┌───── 虚线轨道圆环 ─────┐ │ * 圆形 dot ──────引线──────┤ │ - * │ │ ◎ centerImage │ + * │ │ ◎ titleImage │ * │ │ (大圆人像) │ * │ └───────────────────────┘ * └─────────────────────────────────────────────┘ * - * - centerImage:圆形大图,位于版面中心 - * - 虚线轨道:紧贴 centerImage 外侧的圆环,提供时间线的视觉骨架 + * - titleImage:可选中心图,位于版面中心 + * - 虚线轨道:紧贴中心区域外侧的圆环,提供时间线的视觉骨架 * - 每个 block 由 1 个圆形小图(dot)压在轨道上,外加一段从 dot 引出的 title + content 文字 * - block 沿轨道环绕分布(默认 360°);左半圆 block 文字 right-align,右半圆 block 文字 left-align */ // ===== 半径配置(按可用半径的比例划分各圈层)===== const CLOCK_CENTER_RADIUS_RATIO = 0.6; // 中心圆半径(更大) -const CLOCK_CENTER_IMAGE_INSET_RATIO = 0.9; // centerImage 相对中心圆的尺寸比例(留出环形空隙) +const CLOCK_CENTER_IMAGE_INSET_RATIO = 0.9; // titleImage 相对中心圆的尺寸比例(留出环形空隙) const CLOCK_ORBIT_RATIO = 0.68; // 虚线轨道半径 const CLOCK_DOT_RATIO = 0.68; // 圆形小图(dot)中心所在半径(与轨道重合) const CLOCK_TEXT_INNER_RATIO = 0.92; // block 文字段起始半径(距离圆心更远) @@ -103,18 +107,18 @@ const isOnLeftHalf = (angle: number) => Math.cos(angle) < 0; // ===== 中心圆 ===== -export const buildClockCenterImageMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { - if (spec.centerImage?.visible === false) { +export const buildClockTitleImageMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + if (spec.titleImage?.visible === false) { return null; } const themeColor = getThemeColor(spec); - const hasImage = !!spec.centerImage?.image; + const hasImage = !!spec.titleImage?.image; return { type: 'group' as any, name: 'storyline-clock-center', zIndex: LayoutZIndex.Mark + 2, children: [ - // centerImage 背后的高亮光晕(主题色透明色,营造"焦点"效果) + // titleImage 背后的高亮光晕(主题色透明色,营造"焦点"效果) { type: 'symbol', name: 'storyline-clock-center-halo', @@ -149,10 +153,10 @@ export const buildClockCenterImageMark = (spec: IStorylineSpec): IExtensionGroup getClockGeometry(spec, ctx).R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO * 2, height: (_d: unknown, ctx: LayoutContext) => getClockGeometry(spec, ctx).R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO * 2, - image: spec.centerImage?.image, + image: spec.titleImage?.image, repeatX: 'no-repeat', repeatY: 'no-repeat', - imageMode: 'cover', + imageMode: 'contain', imagePosition: 'center', cornerRadius: (_d: unknown, ctx: LayoutContext) => getClockGeometry(spec, ctx).R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO, @@ -165,18 +169,18 @@ export const buildClockCenterImageMark = (spec: IStorylineSpec): IExtensionGroup dx: (_d: unknown, ctx: LayoutContext) => { const g = getClockGeometry(spec, ctx); const rectW = g.R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO * 2; - const userWidth = (spec.centerImage?.style as { width?: number } | undefined)?.width; + const userWidth = (spec.titleImage?.style as { width?: number } | undefined)?.width; const w = typeof userWidth === 'number' ? userWidth : rectW; return (rectW - w) / 2; }, dy: (_d: unknown, ctx: LayoutContext) => { const g = getClockGeometry(spec, ctx); const rectH = g.R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO * 2; - const userHeight = (spec.centerImage?.style as { height?: number } | undefined)?.height; + const userHeight = (spec.titleImage?.style as { height?: number } | undefined)?.height; const h = typeof userHeight === 'number' ? userHeight : rectH; return (rectH - h) / 2; }, - ...spec.centerImage?.style + ...spec.titleImage?.style } } as ICustomMarkSpec<'image'>) : ({ @@ -200,7 +204,7 @@ export const buildClockCenterImageMark = (spec: IStorylineSpec): IExtensionGroup // ===== 虚线轨道 ===== /** - * 紧贴 centerImage 外侧的虚线圆环轨道。 + * 紧贴中心区域外侧的虚线圆环轨道。 */ export const buildClockArcMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { const themeColor = getThemeColor(spec); @@ -292,6 +296,17 @@ export const buildClockBlockMark = ( const themeColor = getThemeColor(spec); const showImageBackground = shouldShowImageBackground(spec); const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; + const getTitleFontSize = (ctx: LayoutContext) => + resolveTitleFontSize( + spec, + ctx, + block.title, + getClockTextRect(spec, ctx, index).width, + CLOCK_TITLE_FONT_SIZE, + [8, 30] + ); + const getTitleLineHeight = (ctx: LayoutContext) => + resolveAdaptiveLineHeight(getTitleFontSize(ctx), spec.title?.style as any, CLOCK_TITLE_LINE_HEIGHT, 1.28); const leadPath = (_d: unknown, ctx: LayoutContext) => { const { start, end } = getClockLeadLine(spec, ctx, index); @@ -323,9 +338,7 @@ export const buildClockBlockMark = ( y: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).y, size: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).diameter + 10, symbolType: 'circle', - fill: '#ffffff', - stroke: themeColor, - lineWidth: 2 + ...getImageBackgroundStyle(spec) } } as ICustomMarkSpec<'symbol'>) : null, @@ -335,6 +348,7 @@ export const buildClockBlockMark = ( type: 'image', name: `storyline-clock-dot-${index}`, interactive: false, + ...omitImageLayoutSpec(spec.image), style: { x: (_d: unknown, ctx: LayoutContext) => { const dot = getClockDotCenter(spec, ctx, index); @@ -349,9 +363,10 @@ export const buildClockBlockMark = ( image: block.image, repeatX: 'no-repeat', repeatY: 'no-repeat', - imageMode: 'cover', + imageMode: 'contain', imagePosition: 'center', - cornerRadius: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).diameter / 2 + cornerRadius: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).diameter / 2, + ...spec.image?.style } } as ICustomMarkSpec<'image'>) : ({ @@ -378,11 +393,11 @@ export const buildClockBlockMark = ( style: { x: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).x, y: (_d: unknown, ctx: LayoutContext) => - getClockTextRect(spec, ctx, index).anchorY - CLOCK_TITLE_LINE_HEIGHT, + getClockTextRect(spec, ctx, index).anchorY - getTitleLineHeight(ctx), text: block.title, maxLineWidth: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).width, - fontSize: CLOCK_TITLE_FONT_SIZE, - lineHeight: CLOCK_TITLE_LINE_HEIGHT, + fontSize: (_d: unknown, ctx: LayoutContext) => getTitleFontSize(ctx), + lineHeight: (_d: unknown, ctx: LayoutContext) => getTitleLineHeight(ctx), fontWeight: 'bold', fill: themeColor, stroke: '#fff', diff --git a/packages/vchart-extension/src/charts/storyline/layouts/common.ts b/packages/vchart-extension/src/charts/storyline/layouts/common.ts index 9f36d42cbd..5f3c634c47 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/common.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/common.ts @@ -1,4 +1,4 @@ -import type { ICustomMarkSpec } from '@visactor/vchart'; +import { LayoutZIndex, type ICustomMarkSpec } from '@visactor/vchart'; import type { IStorylineBlock, IStorylineSpec, StorylineImagePosition } from '../interface'; import { computeStorylineLayout, @@ -31,6 +31,11 @@ export const DEFAULT_IMAGE_WIDTH = 48; export const DEFAULT_IMAGE_HEIGHT = 48; export const DEFAULT_IMAGE_GAP = 10; export const DEFAULT_THEME_COLOR = '#e8543d'; +const DEFAULT_TITLE_IMAGE_WIDTH_RATIO = 0.52; +const DEFAULT_TITLE_IMAGE_MAX_WIDTH = 720; +const DEFAULT_TITLE_IMAGE_HEIGHT_RATIO = 0.36; +const DEFAULT_TITLE_IMAGE_TOP = 12; +const DEFAULT_TITLE_IMAGE_BOTTOM = 24; // ===== 布局判定 ===== @@ -46,6 +51,228 @@ export const getThemeColor = (spec: IStorylineSpec) => spec.themeColor ?? DEFAUL export const shouldShowImageBackground = (spec: IStorylineSpec) => spec.image?.showBackground ?? !(isPortrait(spec) || isLandscape(spec)); +// ===== 默认样式工具 ===== + +const TITLE_FONT_SCALE_ID = 'storylineTitleFontSize'; +const MARKER_FONT_SCALE_ID = 'storylineMarkerFontSize'; + +const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)); + +const getTextWeight = (text?: string) => { + if (!text) { + return 4; + } + return Math.max( + Array.from(text).reduce((sum, char) => { + if (char.trim().length === 0) { + return sum + 0.32; + } + return sum + (char.charCodeAt(0) > 255 ? 1.05 : 0.62); + }, 0), + 1 + ); +}; + +const getScaleRange = (spec: IStorylineSpec, scaleId: string, fallback: [number, number]) => { + const scales = (spec as { scales?: { id?: string; type?: string; range?: unknown }[] }).scales; + const range = scales?.find(scale => scale.id === scaleId || scale.type === scaleId)?.range; + if (Array.isArray(range) && range.length >= 2 && typeof range[0] === 'number' && typeof range[1] === 'number') { + return [Math.min(range[0], range[1]), Math.max(range[0], range[1])] as [number, number]; + } + return fallback; +}; + +const getSpecGeometry = (spec: IStorylineSpec, ctx?: LayoutContext) => { + if (ctx) { + return getRegionGeometry(ctx, spec); + } + return { + width: Math.max(Number(spec.width ?? 0), 1), + height: Math.max(Number(spec.height ?? 0), 1), + startX: 0, + startY: 0 + }; +}; + +const resolveAdaptiveFontSize = ( + spec: IStorylineSpec, + ctx: LayoutContext | undefined, + text: string | undefined, + options: { + style?: Record; + scaleId: string; + fallback: number; + range: [number, number]; + canvasRatio: number; + boxWidth?: number; + boxHeight?: number; + } +) => { + const configuredFontSize = options.style?.fontSize; + if (configuredFontSize != null) { + return Number(configuredFontSize); + } + const [minFontSize, maxFontSize] = getScaleRange(spec, options.scaleId, options.range); + const { width, height } = getSpecGeometry(spec, ctx); + const textWeight = getTextWeight(text); + const canvasSize = Math.sqrt(width * height) * options.canvasRatio; + const lengthFactor = Math.sqrt(8 / Math.max(textWeight, 4)); + const adaptiveSize = width <= 1 && height <= 1 ? options.fallback : canvasSize * lengthFactor; + const boxWidthLimit = + options.boxWidth && options.boxWidth > 0 ? (options.boxWidth / textWeight) * 0.96 : Number.POSITIVE_INFINITY; + const boxHeightLimit = + options.boxHeight && options.boxHeight > 0 ? options.boxHeight / Math.max(textWeight, 1) : Number.POSITIVE_INFINITY; + return Math.floor(clamp(Math.min(adaptiveSize, boxWidthLimit, boxHeightLimit), minFontSize, maxFontSize)); +}; + +export const resolveAdaptiveLineHeight = ( + fontSize: number, + style: Record | undefined, + fallback: number, + ratio = 1.35 +) => Number(style?.lineHeight ?? Math.round((Number.isFinite(fontSize) ? fontSize : fallback) * ratio)); + +export const resolveTitleFontSize = ( + spec: IStorylineSpec, + ctx: LayoutContext | undefined, + title: string | undefined, + boxWidth: number | undefined, + fallback: number, + range: [number, number] = [16, 34] +) => + resolveAdaptiveFontSize(spec, ctx, title, { + style: spec.title?.style as Record | undefined, + scaleId: TITLE_FONT_SCALE_ID, + fallback, + range, + canvasRatio: 0.038, + boxWidth + }); + +export const resolveMarkerFontSize = ( + spec: IStorylineSpec, + ctx: LayoutContext, + marker: string | undefined, + boxHeight: number | undefined, + fallback: number, + range: [number, number] = [18, 46] +) => + resolveAdaptiveFontSize(spec, ctx, marker, { + style: spec.marker?.style as Record | undefined, + scaleId: MARKER_FONT_SCALE_ID, + fallback, + range, + canvasRatio: 0.052, + boxHeight + }); + +export const getImageBackgroundStyle = (spec: IStorylineSpec) => { + const themeColor = getThemeColor(spec); + return { + fill: { + gradient: 'linear', + x0: 0, + y0: 0, + x1: 1, + y1: 1, + stops: [ + { offset: 0, color: '#ffffff' }, + { offset: 0.58, color: withAlpha(themeColor, 0.12) }, + { offset: 1, color: withAlpha(themeColor, 0.32) } + ] + }, + stroke: withAlpha(themeColor, 0.78), + lineWidth: 2, + shadowColor: withAlpha(themeColor, 0.18), + shadowBlur: 10, + shadowOffsetX: 0, + shadowOffsetY: 4 + }; +}; + +export const getTitleImageSize = ( + spec: IStorylineSpec, + width: number, + height: number, + options?: { widthRatio?: number; maxWidth?: number; heightRatio?: number } +) => { + const defaultWidth = Math.min( + Math.max(width * (options?.widthRatio ?? DEFAULT_TITLE_IMAGE_WIDTH_RATIO), 1), + options?.maxWidth ?? DEFAULT_TITLE_IMAGE_MAX_WIDTH + ); + const imageWidth = Math.max(Number(spec.titleImage?.width ?? defaultWidth), 1); + const imageHeight = Math.max( + Number( + spec.titleImage?.height ?? + Math.min(height, imageWidth * (options?.heightRatio ?? DEFAULT_TITLE_IMAGE_HEIGHT_RATIO)) + ), + 1 + ); + return { width: imageWidth, height: imageHeight }; +}; + +export const getTitleImageReservedHeight = ( + spec: IStorylineSpec, + width: number, + height: number, + options?: { y?: number; widthRatio?: number; maxWidth?: number; heightRatio?: number; bottom?: number } +) => { + if (!spec.titleImage?.image || spec.titleImage.visible === false) { + return 0; + } + const size = getTitleImageSize(spec, width, height, options); + return Math.ceil( + (options?.y ?? DEFAULT_TITLE_IMAGE_TOP) + size.height + (options?.bottom ?? DEFAULT_TITLE_IMAGE_BOTTOM) + ); +}; + +export const getChartGeometry = (ctx: LayoutContext, spec?: { width?: number; height?: number }) => { + const chartRect = ctx.chart?.getLayoutRect?.(); + const bounds = ctx.getLayoutBounds?.(); + const width = Math.max(chartRect?.width ?? bounds?.width?.() ?? spec?.width ?? 0, 1); + const height = Math.max(chartRect?.height ?? bounds?.height?.() ?? spec?.height ?? 0, 1); + return { width, height, startX: 0, startY: 0 }; +}; + +export const buildTopTitleImageMark = ( + spec: IStorylineSpec, + options?: { y?: number; widthRatio?: number; maxWidth?: number; heightRatio?: number } +): ICustomMarkSpec<'image'> | null => { + if (!spec.titleImage?.image || spec.titleImage.visible === false) { + return null; + } + return { + type: 'image', + name: 'storyline-title-image', + interactive: false, + zIndex: LayoutZIndex.Mark + 8, + ...spec.titleImage, + style: { + x: (_d: unknown, ctx: LayoutContext) => { + const { width, height, startX } = getChartGeometry(ctx, spec); + const size = getTitleImageSize(spec, width, height, options); + return startX + (width - size.width) / 2; + }, + y: (_d: unknown, ctx: LayoutContext) => + getChartGeometry(ctx, spec).startY + (options?.y ?? DEFAULT_TITLE_IMAGE_TOP), + width: (_d: unknown, ctx: LayoutContext) => { + const { width, height } = getChartGeometry(ctx, spec); + return getTitleImageSize(spec, width, height, options).width; + }, + height: (_d: unknown, ctx: LayoutContext) => { + const { width, height } = getChartGeometry(ctx, spec); + return getTitleImageSize(spec, width, height, options).height; + }, + image: spec.titleImage.image, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'contain', + imagePosition: 'center', + ...spec.titleImage.style + } + } as ICustomMarkSpec<'image'>; +}; + // ===== 颜色工具 ===== /** @@ -135,7 +362,8 @@ export const getLayout = (spec: IStorylineSpec, ctx: LayoutContext): StorylineLa if (isPortrait(spec) && !spec.block?.height) { const count = spec.data?.length ?? 0; if (count > 0) { - const padding = normalizePadding(spec.layout?.padding ?? spec.block?.padding); + const layoutPadding = typeof spec.layout === 'object' ? spec.layout.padding : undefined; + const padding = normalizePadding(layoutPadding ?? spec.block?.padding); const innerHeight = Math.max(height - padding.top - padding.bottom, 1); blockHeight = Math.max(120, Math.floor(innerHeight / (count + 1))); } diff --git a/packages/vchart-extension/src/charts/storyline/layouts/default.ts b/packages/vchart-extension/src/charts/storyline/layouts/default.ts index 0c4a0462e2..0fbf0ae890 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/default.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/default.ts @@ -10,13 +10,15 @@ import { DEFAULT_IMAGE_HEIGHT, DEFAULT_IMAGE_GAP, buildRichContent, + getImageBackgroundStyle, getImageBox, getLayout, getTextBox, - getThemeColor, normalizePadding, omitImageLayoutSpec, resolveBlockWidth, + resolveAdaptiveLineHeight, + resolveTitleFontSize, shouldShowImageBackground } from './common'; @@ -67,9 +69,6 @@ const getDefaultBlockMetrics = (spec: IStorylineSpec, ctx: LayoutContext, index: const imageHeight = spec.image?.height ?? DEFAULT_IMAGE_HEIGHT; const imageGap = spec.image?.gap ?? DEFAULT_IMAGE_GAP; const hasImage = !!spec.data?.[index]?.image; - const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 18); - const titleLineHeight = Number((spec.title?.style as any)?.lineHeight ?? Math.round(titleFontSize * 1.35)); - const titleHeight = spec.data?.[index]?.title ? titleLineHeight : 0; const blockWidth = block?.width ?? resolveBlockWidth(spec, 0); const blockHeight = block?.height ?? spec.block?.height ?? DEFAULT_BLOCK_HEIGHT; const imageBox = getImageBox( @@ -92,6 +91,9 @@ const getDefaultBlockMetrics = (spec: IStorylineSpec, ctx: LayoutContext, index: imageGap, hasImage ); + const titleFontSize = resolveTitleFontSize(spec, ctx, spec.data?.[index]?.title, textBox.width, 18, [8, 28]); + const titleLineHeight = resolveAdaptiveLineHeight(titleFontSize, spec.title?.style as any, Math.round(18 * 1.35)); + const titleHeight = spec.data?.[index]?.title ? titleLineHeight : 0; const contentGap = spec.data?.[index]?.title ? 8 : 0; return { @@ -99,6 +101,8 @@ const getDefaultBlockMetrics = (spec: IStorylineSpec, ctx: LayoutContext, index: width: blockWidth, height: blockHeight }, + titleFontSize, + titleLineHeight, imageBox, textBox, contentBox: { @@ -115,9 +119,6 @@ export const buildDefaultBlockMark = ( ): IExtensionGroupMarkSpec => { const hasImage = !!block.image; const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; - const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 18); - const titleLineHeight = Number((spec.title?.style as any)?.lineHeight ?? Math.round(titleFontSize * 1.35)); - const themeColor = getThemeColor(spec); return { type: 'group' as any, @@ -162,9 +163,7 @@ export const buildDefaultBlockMark = ( width: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).imageBox.width, height: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).imageBox.height, cornerRadius: 8, - fill: '#ffffff', - stroke: themeColor, - lineWidth: 2, + ...getImageBackgroundStyle(spec), ...spec.block?.style } } as ICustomMarkSpec<'rect'>) @@ -197,8 +196,9 @@ export const buildDefaultBlockMark = ( text: block.title, maxLineWidth: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).textBox.width, - fontSize: titleFontSize, - lineHeight: titleLineHeight, + fontSize: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).titleFontSize, + lineHeight: (_datum: unknown, ctx: LayoutContext) => + getDefaultBlockMetrics(spec, ctx, index).titleLineHeight, fontWeight: 'bold', fill: '#1f2430', stroke: '#fff', diff --git a/packages/vchart-extension/src/charts/storyline/layouts/ladder.ts b/packages/vchart-extension/src/charts/storyline/layouts/ladder.ts index 3c7b20dd56..6460ecd1e3 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/ladder.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/ladder.ts @@ -6,6 +6,7 @@ import { DEFAULT_BLOCK_HEIGHT, DEFAULT_IMAGE_GAP, buildRichContent, + getImageBackgroundStyle, getImageBox, getLayout, getRegionGeometry, @@ -14,7 +15,9 @@ import { normalizeLayout, normalizePadding, omitImageLayoutSpec, + resolveAdaptiveLineHeight, resolveBlockWidth, + resolveTitleFontSize, shouldShowImageBackground, withAlpha } from './common'; @@ -193,14 +196,6 @@ const getLadderBlockMetrics = (spec: IStorylineSpec, ctx: LayoutContext, index: const imageHeight = spec.image?.height ?? LADDER_BLOCK_IMAGE_SIZE; const imageGap = spec.image?.gap ?? DEFAULT_IMAGE_GAP; const hasImage = !!spec.data?.[index]?.image; - const titleFontSize = Number( - (spec.title?.style as { fontSize?: number } | undefined)?.fontSize ?? LADDER_TITLE_FONT_SIZE - ); - const titleLineHeight = Number( - (spec.title?.style as { lineHeight?: number } | undefined)?.lineHeight ?? - Math.round(titleFontSize * (LADDER_TITLE_LINE_HEIGHT / LADDER_TITLE_FONT_SIZE)) - ); - const titleHeight = spec.data?.[index]?.title ? titleLineHeight : 0; const blockWidth = block?.width ?? resolveBlockWidth(spec, 0); const blockHeight = block?.height ?? spec.block?.height ?? DEFAULT_BLOCK_HEIGHT; const imageBox = getImageBox( @@ -223,9 +218,26 @@ const getLadderBlockMetrics = (spec: IStorylineSpec, ctx: LayoutContext, index: imageGap, hasImage ); + const titleFontSize = resolveTitleFontSize( + spec, + ctx, + spec.data?.[index]?.title, + textBox.width, + LADDER_TITLE_FONT_SIZE, + [8, 34] + ); + const titleLineHeight = resolveAdaptiveLineHeight( + titleFontSize, + spec.title?.style as any, + LADDER_TITLE_LINE_HEIGHT, + LADDER_TITLE_LINE_HEIGHT / LADDER_TITLE_FONT_SIZE + ); + const titleHeight = spec.data?.[index]?.title ? titleLineHeight : 0; const contentGap = spec.data?.[index]?.title ? 8 : 0; return { block: { width: blockWidth, height: blockHeight }, + titleFontSize, + titleLineHeight, imageBox, textBox, contentBox: { @@ -249,16 +261,8 @@ export const buildLadderBlockMark = ( const onLeft = isOnLeft(index); const align = onLeft ? 'right' : 'left'; const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; - const titleFontSize = Number( - (spec.title?.style as { fontSize?: number } | undefined)?.fontSize ?? LADDER_TITLE_FONT_SIZE - ); - const titleLineHeight = Number( - (spec.title?.style as { lineHeight?: number } | undefined)?.lineHeight ?? - Math.round(titleFontSize * (LADDER_TITLE_LINE_HEIGHT / LADDER_TITLE_FONT_SIZE)) - ); const showBackground = spec.block?.showBackground === true; const showImageBackground = shouldShowImageBackground(spec); - const themeColor = getThemeColor(spec); // textAlign='right' 时 x 锚点取 textBox 右端,否则取左端 const getTitleX = (ctx: LayoutContext) => { @@ -309,9 +313,7 @@ export const buildLadderBlockMark = ( width: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).imageBox.width, height: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).imageBox.height, cornerRadius: 8, - fill: '#ffffff', - stroke: themeColor, - lineWidth: 2, + ...getImageBackgroundStyle(spec), ...spec.block?.style } } as ICustomMarkSpec<'rect'>) @@ -343,8 +345,8 @@ export const buildLadderBlockMark = ( y: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).textBox.y, text: block.title, maxLineWidth: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).textBox.width, - fontSize: titleFontSize, - lineHeight: titleLineHeight, + fontSize: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).titleFontSize, + lineHeight: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).titleLineHeight, fontWeight: 'bold', fill: '#1f2430', stroke: '#fff', diff --git a/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts b/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts index 74fa951022..0ed2d604dd 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts @@ -8,11 +8,14 @@ import { DEFAULT_BLOCK_HEIGHT, buildRichContent, buildSmoothCurvePath, + getImageBackgroundStyle, getLayout, getThemeColor, normalizePadding, omitImageLayoutSpec, + resolveAdaptiveLineHeight, resolveBlockWidth, + resolveTitleFontSize, shouldShowImageBackground } from './common'; @@ -106,11 +109,19 @@ export const buildLandscapeConnectingCurve = (spec: IStorylineSpec): IExtensionG * landscape 布局下,每个 block 拆分为 image rect 与 text rect 两个独立卡片, * 中间用主题色虚线箭头连接;title+content 在 image 上方/下方交替错落摆放。 */ -const getLandscapeMetrics = (spec: IStorylineSpec, blockWidth: number, blockHeight: number, index: number) => { +const getLandscapeMetrics = ( + spec: IStorylineSpec, + blockWidth: number, + blockHeight: number, + index: number, + ctx: LayoutContext +) => { const padding = normalizePadding(spec.block?.padding ?? 12); - const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 26); - const titleLineHeight = Number( - (spec.title?.style as any)?.lineHeight ?? Math.max(LANDSCAPE_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.35)) + const titleFontSize = resolveTitleFontSize(spec, ctx, spec.data?.[index]?.title, blockWidth, 26, [8, 34]); + const titleLineHeight = resolveAdaptiveLineHeight( + titleFontSize, + spec.title?.style as any, + LANDSCAPE_TITLE_LINE_HEIGHT ); const contentFontSize = Number((spec.content?.style as any)?.fontSize ?? LANDSCAPE_CONTENT_FONT_SIZE); const contentLineHeight = Number((spec.content?.style as any)?.lineHeight ?? LANDSCAPE_CONTENT_LINE_HEIGHT); @@ -203,14 +214,12 @@ export const buildLandscapeBlockMark = ( ): IExtensionGroupMarkSpec => { const hasImage = !!block.image; const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; - const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 26); - const titleLineHeight = Number((spec.title?.style as any)?.lineHeight ?? Math.round(titleFontSize * 1.35)); const getMetrics = (ctx: LayoutContext) => { const layoutBlock = getLayout(spec, ctx).blocks[index]; const w = layoutBlock?.width ?? resolveBlockWidth(spec, 0); const h = layoutBlock?.height ?? spec.block?.height ?? DEFAULT_BLOCK_HEIGHT; - return getLandscapeMetrics(spec, w, h, index); + return getLandscapeMetrics(spec, w, h, index, ctx); }; const blockStyle = spec.block?.style ?? {}; @@ -254,9 +263,7 @@ export const buildLandscapeBlockMark = ( width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.width, height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.height, cornerRadius: 8, - fill: '#ffffff', - stroke: themeColor, - lineWidth: 2, + ...getImageBackgroundStyle(spec), ...blockStyle } } as ICustomMarkSpec<'rect'>) @@ -275,7 +282,7 @@ export const buildLandscapeBlockMark = ( image: block.image, repeatX: 'no-repeat', repeatY: 'no-repeat', - imageMode: 'cover', + imageMode: 'contain', imagePosition: 'center', ...spec.image?.style } @@ -315,8 +322,8 @@ export const buildLandscapeBlockMark = ( y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textBox.y, text: block.title, maxLineWidth: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textBox.width, - fontSize: titleFontSize, - lineHeight: titleLineHeight, + fontSize: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleFontSize, + lineHeight: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleLineHeight, fontWeight: 'bold', fill: '#1f2430', stroke: '#fff', diff --git a/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts b/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts index 68e00826da..96a35921c0 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts @@ -6,10 +6,13 @@ import { type LayoutContext, DEFAULT_BLOCK_HEIGHT, buildRichContent, + getImageBackgroundStyle, getLayout, - getThemeColor, omitImageLayoutSpec, + resolveAdaptiveLineHeight, + resolveMarkerFontSize, resolveBlockWidth, + resolveTitleFontSize, shouldShowImageBackground, withAlpha } from './common'; @@ -17,7 +20,7 @@ import { // portrait 布局:中轴 rect + 左右交替的 image + image 下方 title/content // 中轴默认加宽,便于在轴上叠放 block.marker 时间节点文字(纵向逐字排列) const PORTRAIT_AXIS_WIDTH = 96; -const PORTRAIT_AXIS_PADDING = 50; // 中轴上下两端的留白 +const PORTRAIT_AXIS_PADDING = 120; // 中轴上下两端的留白 // marker 时间节点文字的默认样式(fontSize 30、白色字、贴轴对应一侧边缘) const PORTRAIT_MARKER_FONT_SIZE = 40; const PORTRAIT_MARKER_LINE_HEIGHT = 28; @@ -27,7 +30,7 @@ const PORTRAIT_MARKER_AXIS_PADDING = 6; // marker 距离轴边缘的水平内边 // - content 高度 = slotHeight * (0.6 + 0.4) // 其中 slotHeight = regionHeight / (blockCount + 1),由 getLayout 计算。 export const PORTRAIT_IMAGE_HEIGHT_RATIO = 0.6; -export const PORTRAIT_CONTENT_HEIGHT_RATIO = 1; +export const PORTRAIT_CONTENT_HEIGHT_RATIO = 1.25; const PORTRAIT_IMAGE_GAP_FROM_AXIS = 24; // image 与中轴之间的水平间距 const PORTRAIT_SHADOW_OFFSET_X = 24; // subImage 相对主 image 的水平错位量 const PORTRAIT_SHADOW_OFFSET_Y = 16; // subImage 相对主 image 的垂直错位量 @@ -39,29 +42,8 @@ export const PORTRAIT_CONTENT_LINE_HEIGHT = 26; const PORTRAIT_CONTENT_FONT_SIZE = 18; export const PORTRAIT_TITLE_TO_CONTENT_GAP = 4; -/** - * 获取 portrait 布局的中轴 rect 尺寸:宽度固定,高度贯穿首/尾 block 中心。 - */ -const getPortraitAxisRect = (spec: IStorylineSpec, ctx: LayoutContext) => { - const blocks = getLayout(spec, ctx).blocks; - if (!blocks.length) { - return { x: 0, y: 0, width: 0, height: 0 }; - } - const firstCy = blocks[0].center.y; - const lastCy = blocks[blocks.length - 1].center.y; - const top = Math.min(firstCy, lastCy); - const bottom = Math.max(firstCy, lastCy); - const cx = blocks[0].center.x; - return { - x: cx - PORTRAIT_AXIS_WIDTH / 2, - y: top - PORTRAIT_AXIS_PADDING, - width: PORTRAIT_AXIS_WIDTH, - height: bottom - top + PORTRAIT_AXIS_PADDING * 2 - }; -}; - export const buildPortraitAxisMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec => { - const themeColor = getThemeColor(spec); + const themeColor = spec.themeColor ?? '#e8543d'; const lineStyle = spec.line?.style ?? {}; const defaultFill = { gradient: 'linear', @@ -75,11 +57,7 @@ export const buildPortraitAxisMark = (spec: IStorylineSpec): IExtensionGroupMark ] }; // marker 时间节点文字:垂直方向逐字排列(每字符换行) - const markerFontSize = Number((spec.marker?.style as any)?.fontSize ?? PORTRAIT_MARKER_FONT_SIZE); - const markerLineHeight = Number((spec.marker?.style as any)?.lineHeight ?? PORTRAIT_MARKER_LINE_HEIGHT); const markerVisible = spec.marker?.visible !== false; - // 把 "2012" 拆成 "2\n0\n1\n2",由 text mark 的多行文本能力实现纵向排列 - const splitVertical = (text: string) => text.split('').join('\n'); const markerMarks = markerVisible ? (spec.data ?? []) @@ -110,15 +88,31 @@ export const buildPortraitAxisMark = (spec: IStorylineSpec): IExtensionGroupMark const lb = getLayout(spec, ctx).blocks[index]; return lb?.center?.y ?? 0; }, - text: { - type: 'rich', - text: block.marker.split('').map((char, i, arr) => ({ - text: char + (i < arr.length - 1 ? '\n' : ''), - fontSize: markerFontSize, - lineHeight: markerLineHeight, - fill: '#fff', - align: markerTextAlign - })) + text: (_d: unknown, ctx: LayoutContext) => { + const axis = getPortraitAxisRect(spec, ctx); + const markerFontSize = resolveMarkerFontSize( + spec, + ctx, + block.marker, + axis.height / Math.max(spec.data?.length ?? 1, 1), + PORTRAIT_MARKER_FONT_SIZE + ); + const markerLineHeight = resolveAdaptiveLineHeight( + markerFontSize, + spec.marker?.style as any, + PORTRAIT_MARKER_LINE_HEIGHT, + 0.9 + ); + return { + type: 'rich', + text: block.marker.split('').map((char, i, arr) => ({ + text: char + (i < arr.length - 1 ? '\n' : ''), + fontSize: markerFontSize, + lineHeight: markerLineHeight, + fill: '#fff', + align: markerTextAlign + })) + }; }, fontWeight: 'bold', lineJoin: 'round', @@ -160,11 +154,13 @@ export const buildPortraitAxisMark = (spec: IStorylineSpec): IExtensionGroupMark }; }; -const getPortraitMetrics = (spec: IStorylineSpec, blockWidth: number, blockHeight: number, index: number) => { - const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 26); - const titleLineHeight = Number( - (spec.title?.style as any)?.lineHeight ?? Math.max(PORTRAIT_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.35)) - ); +const getPortraitMetrics = ( + spec: IStorylineSpec, + blockWidth: number, + blockHeight: number, + index: number, + ctx: LayoutContext +) => { const contentFontSize = Number((spec.content?.style as any)?.fontSize ?? PORTRAIT_CONTENT_FONT_SIZE); const contentLineHeight = Number((spec.content?.style as any)?.lineHeight ?? PORTRAIT_CONTENT_LINE_HEIGHT); const titleToContentGap = PORTRAIT_TITLE_TO_CONTENT_GAP; @@ -173,6 +169,12 @@ const getPortraitMetrics = (spec: IStorylineSpec, blockWidth: number, blockHeigh // 默认 image 宽度 = blockWidth,让 image 横向自适应单个 block 槽位宽度 const imageWidth = spec.image?.width ?? Math.max(blockWidth, 80); const imageHeight = spec.image?.height ?? Math.round(blockHeight * PORTRAIT_IMAGE_HEIGHT_RATIO); + const titleFontSize = resolveTitleFontSize(spec, ctx, spec.data?.[index]?.title, imageWidth, 26, [8, 34]); + const titleLineHeight = resolveAdaptiveLineHeight( + titleFontSize, + spec.title?.style as any, + PORTRAIT_TITLE_LINE_HEIGHT + ); const minContentHeight = PORTRAIT_CONTENT_LINES * contentLineHeight; // 默认 content 高度 = blockHeight * 0.4 const contentHeight = Math.max(minContentHeight, Math.round(blockHeight * PORTRAIT_CONTENT_HEIGHT_RATIO)); @@ -225,6 +227,37 @@ const getPortraitMetrics = (spec: IStorylineSpec, blockWidth: number, blockHeigh }; }; +/** + * 获取 portrait 布局的中轴 rect 尺寸:宽度固定,高度覆盖首尾 block 的完整内容范围。 + */ +const getPortraitAxisRect = (spec: IStorylineSpec, ctx: LayoutContext) => { + const blocks = getLayout(spec, ctx).blocks; + if (!blocks.length) { + return { x: 0, y: 0, width: 0, height: 0 }; + } + let top = Number.POSITIVE_INFINITY; + let bottom = Number.NEGATIVE_INFINITY; + blocks.forEach((block, index) => { + const metrics = getPortraitMetrics(spec, block.width, block.height, index, ctx); + const localTop = Math.min(metrics.imageBox.y, metrics.shadowBox.y, metrics.textBox.y, metrics.contentBox.y); + const localBottom = Math.max( + metrics.imageBox.y + metrics.imageBox.height, + metrics.shadowBox.y + metrics.shadowBox.height, + metrics.textBox.y + metrics.textBox.height, + metrics.contentBox.y + metrics.contentBox.height + ); + top = Math.min(top, block.center.y + localTop); + bottom = Math.max(bottom, block.center.y + localBottom); + }); + const cx = blocks[0].center.x; + return { + x: cx - PORTRAIT_AXIS_WIDTH / 2, + y: top - PORTRAIT_AXIS_PADDING, + width: PORTRAIT_AXIS_WIDTH, + height: bottom - top + PORTRAIT_AXIS_PADDING * 2 + }; +}; + export const buildPortraitBlockMark = ( spec: IStorylineSpec, block: IStorylineBlock, @@ -233,18 +266,13 @@ export const buildPortraitBlockMark = ( const hasImage = !!block.image; const hasSubImage = !!block.subImage; const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; - const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 26); - const titleLineHeight = Number( - (spec.title?.style as any)?.lineHeight ?? Math.max(PORTRAIT_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.35)) - ); const getMetrics = (ctx: LayoutContext) => { const lb = getLayout(spec, ctx).blocks[index]; const w = lb?.width ?? resolveBlockWidth(spec, 0); const h = lb?.height ?? spec.block?.height ?? DEFAULT_BLOCK_HEIGHT; - return getPortraitMetrics(spec, w, h, index); + return getPortraitMetrics(spec, w, h, index, ctx); }; - const themeColor = getThemeColor(spec); const blockStyle = spec.block?.style ?? {}; return { @@ -276,7 +304,7 @@ export const buildPortraitBlockMark = ( image: block.subImage, repeatX: 'no-repeat', repeatY: 'no-repeat', - imageMode: 'cover', + imageMode: 'contain', imagePosition: 'center' } } as ICustomMarkSpec<'image'>) @@ -292,9 +320,7 @@ export const buildPortraitBlockMark = ( width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.width, height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.height, cornerRadius: 8, - fill: '#ffffff', - stroke: themeColor, - lineWidth: 2, + ...getImageBackgroundStyle(spec), ...blockStyle } } as ICustomMarkSpec<'rect'>) @@ -313,7 +339,7 @@ export const buildPortraitBlockMark = ( image: block.image, repeatX: 'no-repeat', repeatY: 'no-repeat', - imageMode: 'cover', + imageMode: 'contain', imagePosition: 'center', ...spec.image?.style } @@ -330,8 +356,8 @@ export const buildPortraitBlockMark = ( y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textBox.y, text: block.title, maxLineWidth: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textBox.width, - fontSize: titleFontSize, - lineHeight: titleLineHeight, + fontSize: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleFontSize, + lineHeight: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleLineHeight, fontWeight: 'bold', fill: '#1f2430', stroke: '#fff', diff --git a/packages/vchart-extension/src/charts/storyline/layouts/wing.ts b/packages/vchart-extension/src/charts/storyline/layouts/wing.ts index 31d75479c0..f35a76f33b 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/wing.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/wing.ts @@ -6,12 +6,16 @@ import { type LayoutContext, type StorylinePoint, buildRichContent, + getImageBackgroundStyle, + getChartGeometry, getRegionGeometry, getThemeColor, + getTitleImageSize, normalizeLayout, omitImageLayoutSpec, - shouldShowImageBackground, - withAlpha + resolveAdaptiveLineHeight, + resolveTitleFontSize, + shouldShowImageBackground } from './common'; // wing 布局:参考残奥时间线信息图 @@ -32,9 +36,11 @@ const WING_TEXT_BOX_WIDTH = 240; // title + content 区域总高度 const WING_TEXT_BOX_HEIGHT = 110; const WING_TITLE_TO_CONTENT_GAP = 4; +const WING_TITLE_IMAGE_WIDTH_RATIO = 0.6; +const WING_TITLE_IMAGE_MAX_WIDTH = 820; const getWingDirection = (spec: IStorylineSpec): StorylineWingDirection => { - return normalizeLayout(spec.layout).direction ?? 'left'; + return normalizeLayout(spec.layout).direction === 'right' ? 'right' : 'left'; }; /** @@ -160,14 +166,75 @@ export const buildWingArcMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec }; }; +export const buildWingTitleImageMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + if (!spec.titleImage?.image || spec.titleImage.visible === false) { + return null; + } + return { + type: 'group' as any, + name: 'storyline-wing-title-image', + zIndex: LayoutZIndex.Mark + 8, + children: [ + { + type: 'image', + name: 'storyline-wing-title-image-node', + interactive: false, + ...spec.titleImage, + style: { + x: (_d: unknown, ctx: LayoutContext) => { + const { width, height, startX } = getChartGeometry(ctx, spec); + const size = getTitleImageSize(spec, width, height, { + widthRatio: WING_TITLE_IMAGE_WIDTH_RATIO, + maxWidth: WING_TITLE_IMAGE_MAX_WIDTH + }); + return getWingDirection(spec) === 'right' ? startX : startX + width - size.width; + }, + y: (_d: unknown, ctx: LayoutContext) => { + return getChartGeometry(ctx, spec).startY + 12; + }, + width: (_d: unknown, ctx: LayoutContext) => { + const { width, height } = getChartGeometry(ctx, spec); + return getTitleImageSize(spec, width, height, { + widthRatio: WING_TITLE_IMAGE_WIDTH_RATIO, + maxWidth: WING_TITLE_IMAGE_MAX_WIDTH + }).width; + }, + height: (_d: unknown, ctx: LayoutContext) => { + const { width, height } = getChartGeometry(ctx, spec); + return getTitleImageSize(spec, width, height, { + widthRatio: WING_TITLE_IMAGE_WIDTH_RATIO, + maxWidth: WING_TITLE_IMAGE_MAX_WIDTH + }).height; + }, + image: spec.titleImage.image, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'contain', + imagePosition: 'center', + ...spec.titleImage.style + } + } as ICustomMarkSpec<'image'> + ] + }; +}; + // text box 与 image 的水平间距(image 左边缘到 text box 右边缘的距离) const WING_TEXT_IMAGE_GAP = 120; const getWingBlockMetrics = (spec: IStorylineSpec, ctx: LayoutContext, index: number) => { - const titleFontSize = Number((spec.title?.style as Record)?.fontSize ?? WING_TITLE_FONT_SIZE); - const titleLineHeight = Number( - (spec.title?.style as Record)?.lineHeight ?? - Math.max(WING_TITLE_LINE_HEIGHT, Math.round(titleFontSize * 1.3)) + const titleFontSize = resolveTitleFontSize( + spec, + ctx, + spec.data?.[index]?.title, + WING_TEXT_BOX_WIDTH, + WING_TITLE_FONT_SIZE, + [8, 30] + ); + const titleLineHeight = resolveAdaptiveLineHeight( + titleFontSize, + spec.title?.style as Record | undefined, + WING_TITLE_LINE_HEIGHT, + 1.3 ); const contentFontSize = Number((spec.content?.style as Record)?.fontSize ?? WING_CONTENT_FONT_SIZE); const contentLineHeight = Number( @@ -312,8 +379,7 @@ export const buildWingBlockMark = ( getWingBlockMetrics(spec, ctx, index).imageBox.height ) + 12, symbolType: 'circle', - fill: withAlpha(themeColor, 0.18), - stroke: themeColor, + ...getImageBackgroundStyle(spec), lineWidth: 1.5 } } as ICustomMarkSpec<'symbol'>) @@ -337,7 +403,7 @@ export const buildWingBlockMark = ( image: block.image, repeatX: 'no-repeat', repeatY: 'no-repeat', - imageMode: 'cover', + imageMode: 'contain', imagePosition: 'center', ...spec.image?.style } @@ -356,9 +422,7 @@ export const buildWingBlockMark = ( getWingBlockMetrics(spec, ctx, index).imageBox.width, getWingBlockMetrics(spec, ctx, index).imageBox.height ) / 2, - fill: '#ffffff', - stroke: themeColor, - lineWidth: 2 + ...getImageBackgroundStyle(spec) } } as ICustomMarkSpec<'rect'>), block.title diff --git a/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts b/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts index 0b403cb767..c51e360d15 100644 --- a/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts +++ b/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts @@ -10,9 +10,11 @@ import { normalizeLayout, resolveBlockWidth, DEFAULT_BLOCK_WIDTH, - DEFAULT_IMAGE_GAP + DEFAULT_IMAGE_GAP, + buildTopTitleImageMark, + getTitleImageReservedHeight } from './layouts/common'; -import { buildClockArcMark, buildClockBlockMark, buildClockCenterImageMark } from './layouts/clock'; +import { buildClockArcMark, buildClockBlockMark } from './layouts/clock'; import { buildDefaultBlockMark, buildDefaultLineMark } from './layouts/default'; import { buildLandscapeBlockMark, buildLandscapeConnectingCurve } from './layouts/landscape'; import { @@ -21,8 +23,8 @@ import { PORTRAIT_CONTENT_HEIGHT_RATIO, PORTRAIT_IMAGE_HEIGHT_RATIO } from './layouts/portrait'; -import { buildArcBlockMark, buildArcCenterImageMark, buildArcMark } from './layouts/arc'; -import { buildWingArcMark, buildWingBlockMark } from './layouts/wing'; +import { buildArcBlockMark, buildArcMark, buildArcTitleImageMark } from './layouts/arc'; +import { buildWingArcMark, buildWingBlockMark, buildWingTitleImageMark } from './layouts/wing'; import { buildLadderBlockMark, buildLadderDiagonalMark, buildLadderHeadlineMark } from './layouts/ladder'; export class StorylineChartSpecTransformer extends CommonChartSpecTransformer { @@ -46,12 +48,12 @@ export class StorylineChartSpecTransformer extends CommonChartSpecTransformer { const LARGE = 100; @@ -65,6 +67,21 @@ const applyDefaultPadding = (spec: any) => { const ladder = isLadder(spec as IStorylineSpec); const wing = isWing(spec as IStorylineSpec); const clock = isClock(spec as IStorylineSpec); + const topTitleImageReserve = (() => { + if ( + arc || + ladder || + !(spec as IStorylineSpec).titleImage?.image || + (spec as IStorylineSpec).titleImage?.visible === false + ) { + return 0; + } + return getTitleImageReservedHeight( + spec as IStorylineSpec, + Number((spec as IStorylineSpec).width ?? 1000), + Number((spec as IStorylineSpec).height ?? 600) + ); + })(); // clock 辐射式布局:底部和顶部 blocks 的文字会向外延伸,需要在四周围留空间 // portrait 底部 padding:精准预留最后一个 block 的 content 展示空间。 // portrait 几何(layouts/portrait.ts): @@ -133,7 +150,10 @@ const applyDefaultPadding = (spec: any) => { // portrait: 底部留给最后一个 block 的 content // ladder: 四周均为 content 宽度 // 其它:保持原默认 [SMALL, SMALL, LARGE, SMALL] - const defaultTop = clock ? 40 : ladder ? ladderVerticalPadding : arcDown ? 0 : arcUp ? TEXT_RESERVE : SMALL; + const defaultTop = Math.max( + topTitleImageReserve, + clock ? 40 : ladder ? ladderVerticalPadding : arcDown ? 0 : arcUp ? TEXT_RESERVE : SMALL + ); const defaultBottom = clock ? 60 : portrait @@ -165,14 +185,18 @@ const applyDefaultPadding = (spec: any) => { spec.padding = [defaultTop, defaultRight, defaultBottom, defaultLeft]; return; } + if (typeof p === 'number') { + spec.padding = [Math.max(p, topTitleImageReserve), p, p, p]; + return; + } if (Array.isArray(p)) { const [t, r = defaultRight, b, l = defaultLeft] = p; - spec.padding = [t ?? defaultTop, r, b ?? defaultBottom, l]; + spec.padding = [Math.max(t ?? defaultTop, topTitleImageReserve), r, b ?? defaultBottom, l]; return; } if (typeof p === 'object') { spec.padding = { - top: p.top ?? defaultTop, + top: Math.max(p.top ?? defaultTop, topTitleImageReserve), right: p.right ?? defaultRight, bottom: p.bottom ?? defaultBottom, left: p.left ?? defaultLeft @@ -183,33 +207,34 @@ const applyDefaultPadding = (spec: any) => { const buildStorylineMarks = (spec: IStorylineSpec) => { const lineMark = buildLineMark(spec); const blockMarks = (spec.data ?? []).map((block, index) => buildBlockMark(spec, block, index)); + const titleImageMark = buildTopTitleImageMark(spec); // landscape:连接曲线绘制在所有 block 之上,避免被 image 遮挡 if (isLandscape(spec)) { - return [...blockMarks, lineMark].filter(Boolean) as IExtensionGroupMarkSpec[]; + return [titleImageMark, ...blockMarks, lineMark].filter(Boolean) as IExtensionGroupMarkSpec[]; } // portrait:lineMark 是中轴 rect,作为底层背景先绘制 if (isPortrait(spec)) { - return [lineMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; + return [lineMark, titleImageMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; } - // arc:先绘制 centerImage(最底层视觉锚点),再绘制贯穿 block 的弧线,最后绘制 block; - // arc 不绘制 block 之间默认的连接线。direction = 'up' 时 centerImage 贴底(穹顶), - // direction = 'down' 时 centerImage 贴顶(碗形) + // arc:先绘制 titleImage(视觉锚点),再绘制贯穿 block 的弧线,最后绘制 block; + // arc 不绘制 block 之间默认的连接线。direction = 'up' 时 titleImage 贴底(穹顶), + // direction = 'down' 时 titleImage 贴顶(碗形) if (isArc(spec)) { - const centerImageMark = buildArcCenterImageMark(spec); + const arcTitleImageMark = buildArcTitleImageMark(spec); const arcMark = buildArcMark(spec); - return [centerImageMark, arcMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; + return [arcTitleImageMark, arcMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; } - // clock:辐射式信息盘 —— 圆环骨架 + 径向分隔线 → centerImage(盘心)→ blocks(楔形 + 外圈文字) + // clock:辐射式信息盘 —— 圆环骨架 + 径向分隔线 + blocks(楔形 + 外圈文字) if (isClock(spec)) { const ringsMark = buildClockArcMark(spec); - const centerImageMark = buildClockCenterImageMark(spec); - return [ringsMark, ...blockMarks, centerImageMark].filter(Boolean) as IExtensionGroupMarkSpec[]; + return [titleImageMark, ringsMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; } // wing:椭圆弧脉络 + 弧线上的圆形 image + 左右交替排列的 title/content; // 通过 layout.direction 控制翅膀朝向('left' | 'right') if (isWing(spec)) { const arcMark = buildWingArcMark(spec); - return [arcMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; + const wingTitleImageMark = buildWingTitleImageMark(spec); + return [arcMark, wingTitleImageMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; } // ladder:参考 Bauhaus 信息图 —— 对角线 + 沿对角线倾斜的 headline 大字 + 两侧错落 block if (isLadder(spec)) { @@ -217,7 +242,7 @@ const buildStorylineMarks = (spec: IStorylineSpec) => { const headlineMark = buildLadderHeadlineMark(spec); return [diagonalMark, headlineMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; } - return [lineMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; + return [titleImageMark, lineMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; }; const buildLineMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { From b7758db0052b2327f009b5e94ad5f31edf871964 Mon Sep 17 00:00:00 2001 From: skie1997 Date: Wed, 24 Jun 2026 16:33:46 +0800 Subject: [PATCH 8/8] feat: enhance padding and fontsize --- .../runtime/browser/test-page/storyline.ts | 14 +- .../src/charts/storyline/layouts/arc.ts | 133 +++++++++++++++--- .../src/charts/storyline/layouts/clock.ts | 28 ++-- .../src/charts/storyline/layouts/common.ts | 35 +---- .../src/charts/storyline/layouts/default.ts | 26 ++-- .../src/charts/storyline/layouts/ladder.ts | 29 ++-- .../src/charts/storyline/layouts/landscape.ts | 40 ++++-- .../src/charts/storyline/layouts/portrait.ts | 36 +++-- .../src/charts/storyline/layouts/wing.ts | 21 ++- 9 files changed, 250 insertions(+), 112 deletions(-) diff --git a/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts b/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts index 2bc5f72c47..206d31708f 100644 --- a/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts +++ b/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts @@ -151,11 +151,11 @@ const createPortraitSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ // arc:弧形布局,通过 direction 切换 dome('up')/ bowl('down') const createArcSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ type: 'storyline', - padding: [50, 20, 100, 20], + // padding: 0, width: WIDTH, height: HEIGHT, data: buildData(layout), - layout: { type: 'arc', direction: 'down' }, + layout: { type: 'arc', direction: 'up' }, titleImage, themeColor }); @@ -165,7 +165,7 @@ const createClockSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ type: 'storyline', height: HEIGHT, width: WIDTH, - padding: [20, 20, 50, 20], + // padding: [20, 20, 50, 20], layout: 'clock', titleImage, themeColor, @@ -227,7 +227,7 @@ const createClockSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ const createDefaultSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ type: 'storyline', - padding: 20, + // padding: 20, data: buildData(layout), layout, titleImage, @@ -250,7 +250,7 @@ const createDefaultSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ // wing:椭圆弧时间线(参考残奥历史信息图),通过 layout.direction 切换左/右翅膀 const createWingSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ type: 'storyline', - padding: [40, 40, 40, 40], + // padding: [40, 40, 40, 40], height: WIDTH, width: HEIGHT, data: buildData(layout), @@ -265,7 +265,7 @@ const createLadderSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ type: 'storyline', width: 1600, height: 900, - padding: 20, + // padding: 20, layout: { type: 'ladder', direction: 'down', headline: 'ladder' }, themeColor: '#C8102E', background: 'transparent', @@ -376,7 +376,7 @@ const run = () => { window.vchart = cs; }; - select.value = 'clock'; + select.value = 'arc'; render(select.value as StorylineLayoutType); select.addEventListener('change', () => { diff --git a/packages/vchart-extension/src/charts/storyline/layouts/arc.ts b/packages/vchart-extension/src/charts/storyline/layouts/arc.ts index 6373042a5b..3852d40810 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/arc.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/arc.ts @@ -5,7 +5,9 @@ import { type ICustomMarkSpec, type LayoutContext, type StorylinePoint, - buildRichContent, + BLOCK_TITLE_MAX_LINES, + buildPlainContent, + getBlockTitleHeight, getImageBackgroundStyle, getRegionGeometry, getThemeColor, @@ -30,9 +32,9 @@ const ARC_BLOCK_IMAGE_HALO_PADDING = 6; const ARC_TEXT_GAP_FROM_IMAGE = 10; const ARC_TITLE_FONT_SIZE = 32; const ARC_TITLE_LINE_HEIGHT = 34; -const ARC_CONTENT_LINE_HEIGHT = 26; -const ARC_CONTENT_FONT_SIZE = 20; -// title + content 区域总高度(默认 240px,溢出由富文本 heightLimit + ellipsis 自动截断) +const ARC_CONTENT_LINE_HEIGHT = 24; +const ARC_CONTENT_FONT_SIZE = 18; +// title + content 区域总高度(默认 240px,溢出由 heightLimit + ellipsis 自动截断) const ARC_TEXT_BOX_HEIGHT = 240; const ARC_TITLE_TO_CONTENT_GAP = 4; // 引导线与 title/content 之间的水平间距 @@ -45,9 +47,21 @@ const ARC_TITLE_IMAGE_MAX_WIDTH = 900; const ARC_TITLE_IMAGE_HEIGHT_RATIO = 0.34; // 弧线最高/最低点距离 titleImage 顶部/底部的距离 const ARC_GAP_FROM_TITLE_IMAGE = 200; +const ARC_FIT_MARGIN = 8; const isDownArc = (spec: IStorylineSpec) => normalizeLayout(spec.layout).direction === 'down'; +type ArcGeometry = { + cx: number; + cy: number; + rx: number; + ry: number; + startAngle: number; + endAngle: number; + centerTop: number; + centerBottom: number; +}; + /** * 计算 arc 布局 titleImage 的 box:水平居中。 * - up(dome):垂直贴底(位于 inner 区域底部) @@ -84,7 +98,7 @@ const getArcTitleImageRect = (spec: IStorylineSpec, ctx: LayoutContext) => { * → ry = (GAP + titleImageHeight) / (1 - sin(startAngle)) * cy = titleImageTop - ry * sin(startAngle) */ -const getArcGeometry = (spec: IStorylineSpec, ctx: LayoutContext) => { +const getBaseArcGeometry = (spec: IStorylineSpec, ctx: LayoutContext): ArcGeometry => { const { width, startX } = getRegionGeometry(ctx); // width 已经是 VChart 减去 spec.padding 后的 region 宽度 const innerWidth = Math.max(width, 1); @@ -130,8 +144,7 @@ const getArcGeometry = (spec: IStorylineSpec, ctx: LayoutContext) => { * 同时让 block 沿弧线径向向外偏移 imageHeight/2, * 使 image 内边贴在弧线上,image + text 整体位于弧线外侧。 */ -const getArcBlockCenter = (spec: IStorylineSpec, ctx: LayoutContext, index: number): StorylinePoint => { - const arc = getArcGeometry(spec, ctx); +const getArcBlockCenterByGeometry = (spec: IStorylineSpec, arc: ArcGeometry, index: number): StorylinePoint => { const count = spec.data?.length ?? 0; if (count <= 0) { return { x: arc.cx, y: arc.cy }; @@ -151,6 +164,90 @@ const getArcBlockCenter = (spec: IStorylineSpec, ctx: LayoutContext, index: numb return { x: px + nx * offset, y: py + ny * offset }; }; +const getArcBlockBounds = (spec: IStorylineSpec, arc: ArcGeometry, index: number) => { + const center = getArcBlockCenterByGeometry(spec, arc, index); + const metrics = getArcBlockMetrics(spec, index); + const halo = shouldShowImageBackground(spec) ? ARC_BLOCK_IMAGE_HALO_PADDING + ARC_BLOCK_IMAGE_BORDER : 0; + const minX = Math.min(metrics.imageBox.x - halo, metrics.textBox.x, metrics.contentBox.x); + const maxX = Math.max( + metrics.imageBox.x + metrics.imageBox.width + halo, + metrics.textBox.x + metrics.textBox.width, + metrics.contentBox.x + metrics.contentBox.width + ); + const minY = Math.min(metrics.imageBox.y - halo, metrics.textBox.y, metrics.contentBox.y); + const maxY = Math.max( + metrics.imageBox.y + metrics.imageBox.height + halo, + metrics.textBox.y + metrics.textBox.height, + metrics.contentBox.y + metrics.contentBox.height + ); + return { + left: center.x + minX, + right: center.x + maxX, + top: center.y + minY, + bottom: center.y + maxY + }; +}; + +const getArcBlocksBounds = (spec: IStorylineSpec, arc: ArcGeometry) => { + const count = spec.data?.length ?? 0; + if (!count) { + return { left: arc.cx, right: arc.cx, top: arc.cy, bottom: arc.cy }; + } + return Array.from({ length: count }, (_, index) => getArcBlockBounds(spec, arc, index)).reduce( + (bounds, blockBounds) => ({ + left: Math.min(bounds.left, blockBounds.left), + right: Math.max(bounds.right, blockBounds.right), + top: Math.min(bounds.top, blockBounds.top), + bottom: Math.max(bounds.bottom, blockBounds.bottom) + }), + { + left: Number.POSITIVE_INFINITY, + right: Number.NEGATIVE_INFINITY, + top: Number.POSITIVE_INFINITY, + bottom: Number.NEGATIVE_INFINITY + } + ); +}; + +const getArcGeometry = (spec: IStorylineSpec, ctx: LayoutContext): ArcGeometry => { + const arc = getBaseArcGeometry(spec, ctx); + const region = getRegionGeometry(ctx); + const bounds = getArcBlocksBounds(spec, arc); + const fit = { + left: region.startX + ARC_FIT_MARGIN, + right: region.startX + region.width - ARC_FIT_MARGIN, + top: region.startY + ARC_FIT_MARGIN, + bottom: region.startY + region.height - ARC_FIT_MARGIN + }; + let shiftX = 0; + let shiftY = 0; + const boundsWidth = bounds.right - bounds.left; + const boundsHeight = bounds.bottom - bounds.top; + const fitWidth = fit.right - fit.left; + const fitHeight = fit.bottom - fit.top; + + if (boundsWidth > fitWidth) { + shiftX = (fit.left + fit.right - bounds.left - bounds.right) / 2; + } else if (bounds.left < fit.left) { + shiftX = fit.left - bounds.left; + } else if (bounds.right > fit.right) { + shiftX = fit.right - bounds.right; + } + + if (boundsHeight > fitHeight) { + shiftY = (fit.top + fit.bottom - bounds.top - bounds.bottom) / 2; + } else if (bounds.top < fit.top) { + shiftY = fit.top - bounds.top; + } else if (bounds.bottom > fit.bottom) { + shiftY = fit.bottom - bounds.bottom; + } + + return shiftX || shiftY ? { ...arc, cx: arc.cx + shiftX, cy: arc.cy + shiftY } : arc; +}; + +const getArcBlockCenter = (spec: IStorylineSpec, ctx: LayoutContext, index: number): StorylinePoint => + getArcBlockCenterByGeometry(spec, getArcGeometry(spec, ctx), index); + /** * 贯穿所有 block 的弧线 mark(path 通过沿椭圆采样实现,与 arc block 的弧形布局完全重合) * @@ -265,9 +362,10 @@ const getArcBlockMetrics = (spec: IStorylineSpec, index: number = 0) => { [8, 40] ); const titleLineHeight = resolveAdaptiveLineHeight(titleFontSize, spec.title?.style as any, ARC_TITLE_LINE_HEIGHT); + const titleHeight = getBlockTitleHeight(titleLineHeight, spec.data?.[index]?.title); // text 区域总高度固定为 ARC_TEXT_BOX_HEIGHT,content 占除 title 与间距外的全部高度 const textHeight = ARC_TEXT_BOX_HEIGHT; - const contentHeight = Math.max(textHeight - titleLineHeight - titleToContentGap, contentLineHeight); + const contentHeight = Math.max(textHeight - titleHeight - titleToContentGap, contentLineHeight); // 前 1/2 为左侧(奇数 count 时中间块也算左侧),右侧为后 1/2; // 左侧 title/content 右对齐(贴引导线),右侧 title/content 左对齐(贴引导线) @@ -293,7 +391,7 @@ const getArcBlockMetrics = (spec: IStorylineSpec, index: number = 0) => { }; const contentBox = { x: textBox.x, - y: textBox.y + titleLineHeight + titleToContentGap, + y: textBox.y + titleHeight + titleToContentGap, width: textBox.width, height: contentHeight }; @@ -419,6 +517,9 @@ export const buildArcBlockMark = ( y: metrics.textBox.y, text: block.title, maxLineWidth: metrics.textBox.width, + height: metrics.titleLineHeight * BLOCK_TITLE_MAX_LINES, + heightLimit: metrics.titleLineHeight * BLOCK_TITLE_MAX_LINES, + lineClamp: BLOCK_TITLE_MAX_LINES, fontSize: metrics.titleFontSize, lineHeight: metrics.titleLineHeight, fontWeight: 'bold', @@ -428,6 +529,9 @@ export const buildArcBlockMark = ( lineJoin: 'round', textAlign: metrics.textAlign, textBaseline: 'top', + whiteSpace: 'normal', + wordBreak: 'break-word', + ellipsis: '...', ...spec.title?.style } } as ICustomMarkSpec<'text'>) @@ -439,7 +543,6 @@ export const buildArcBlockMark = ( interactive: false, zIndex: LayoutZIndex.Mark + 4, ...spec.content, - textType: 'rich', style: { x: metrics.contentBox.x, y: metrics.contentBox.y, @@ -447,14 +550,12 @@ export const buildArcBlockMark = ( height: metrics.contentBox.height, maxLineWidth: metrics.contentBox.width, heightLimit: metrics.contentBox.height, - text: buildRichContent(contentText, spec, { - fontSize: metrics.contentFontSize, - lineHeight: metrics.contentLineHeight, - fill: '#596173', - align: metrics.textAlign - }), + text: buildPlainContent(contentText), + fontSize: metrics.contentFontSize, + lineHeight: metrics.contentLineHeight, textAlign: metrics.textAlign, textBaseline: 'top', + whiteSpace: 'normal', wordBreak: 'break-word', ellipsis: '...', fill: '#596173', diff --git a/packages/vchart-extension/src/charts/storyline/layouts/clock.ts b/packages/vchart-extension/src/charts/storyline/layouts/clock.ts index a4a6f81dbe..e574f9eeae 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/clock.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/clock.ts @@ -4,7 +4,8 @@ import type { IStorylineBlock, IStorylineSpec } from '../interface'; import { type ICustomMarkSpec, type LayoutContext, - buildRichContent, + BLOCK_TITLE_MAX_LINES, + buildPlainContent, getImageBackgroundStyle, getRegionGeometry, getThemeColor, @@ -53,8 +54,9 @@ const CLOCK_ORBIT_DASH = [4, 4]; // ===== 文字 ===== const CLOCK_TITLE_FONT_SIZE = 22; const CLOCK_TITLE_LINE_HEIGHT = 28; -const CLOCK_CONTENT_FONT_SIZE = 16; -const CLOCK_CONTENT_LINE_HEIGHT = 22; +const CLOCK_CONTENT_FONT_SIZE = 14; +const CLOCK_CONTENT_LINE_HEIGHT = 20; +const CLOCK_CONTENT_LINES = 4; // ===== 几何 ===== @@ -78,7 +80,7 @@ const getClockGeometry = (spec: IStorylineSpec, ctx: LayoutContext): ClockGeomet // - content 在 anchor 远离圆心一侧,需要预留 content 的高度 // - 水平方向:text 从 0.92R 向外延伸 CLOCK_TEXT_MAX_WIDTH const textReserveX = CLOCK_TEXT_MAX_WIDTH; - const textReserveY = 4 + CLOCK_CONTENT_LINE_HEIGHT * 4; + const textReserveY = 4 + CLOCK_CONTENT_LINE_HEIGHT * CLOCK_CONTENT_LINES; const rMaxX = (innerWidth / 2 - textReserveX) / CLOCK_TEXT_INNER_RATIO; const rMaxY = (innerHeight / 2 - textReserveY) / CLOCK_TEXT_INNER_RATIO; const R = Math.max(Math.min(rMaxX, rMaxY), 1); @@ -383,7 +385,7 @@ export const buildClockBlockMark = ( lineWidth: 1.5 } } as ICustomMarkSpec<'symbol'>), - // title:文字段的第一行 + // title:最多两行 block.title ? ({ type: 'text', @@ -393,9 +395,12 @@ export const buildClockBlockMark = ( style: { x: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).x, y: (_d: unknown, ctx: LayoutContext) => - getClockTextRect(spec, ctx, index).anchorY - getTitleLineHeight(ctx), + getClockTextRect(spec, ctx, index).anchorY - getTitleLineHeight(ctx) * BLOCK_TITLE_MAX_LINES, text: block.title, maxLineWidth: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).width, + height: (_d: unknown, ctx: LayoutContext) => getTitleLineHeight(ctx) * BLOCK_TITLE_MAX_LINES, + heightLimit: (_d: unknown, ctx: LayoutContext) => getTitleLineHeight(ctx) * BLOCK_TITLE_MAX_LINES, + lineClamp: BLOCK_TITLE_MAX_LINES, fontSize: (_d: unknown, ctx: LayoutContext) => getTitleFontSize(ctx), lineHeight: (_d: unknown, ctx: LayoutContext) => getTitleLineHeight(ctx), fontWeight: 'bold', @@ -406,30 +411,35 @@ export const buildClockBlockMark = ( textAlign: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).onLeft ? 'right' : 'left', textBaseline: 'top', + whiteSpace: 'normal', + wordBreak: 'break-word', + ellipsis: '...', ...spec.title?.style } } as ICustomMarkSpec<'text'>) : null, - // content:富文本,title 下方 + // content:普通文本,title 下方 contentText.length ? ({ type: 'text', name: `storyline-clock-content-${index}`, interactive: false, ...spec.content, - textType: 'rich', style: { x: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).x, y: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).anchorY + 4, width: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).width, + height: CLOCK_CONTENT_LINE_HEIGHT * CLOCK_CONTENT_LINES, maxLineWidth: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).width, - text: buildRichContent(contentText, spec), + heightLimit: CLOCK_CONTENT_LINE_HEIGHT * CLOCK_CONTENT_LINES, + text: buildPlainContent(contentText), fontSize: CLOCK_CONTENT_FONT_SIZE, lineHeight: CLOCK_CONTENT_LINE_HEIGHT, fill: '#3a3f4d', textAlign: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).onLeft ? 'right' : 'left', textBaseline: 'top', + whiteSpace: 'normal', wordBreak: 'break-word', ...spec.content?.style } diff --git a/packages/vchart-extension/src/charts/storyline/layouts/common.ts b/packages/vchart-extension/src/charts/storyline/layouts/common.ts index 5f3c634c47..955564ad1f 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/common.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/common.ts @@ -31,6 +31,7 @@ export const DEFAULT_IMAGE_WIDTH = 48; export const DEFAULT_IMAGE_HEIGHT = 48; export const DEFAULT_IMAGE_GAP = 10; export const DEFAULT_THEME_COLOR = '#e8543d'; +export const BLOCK_TITLE_MAX_LINES = 2; const DEFAULT_TITLE_IMAGE_WIDTH_RATIO = 0.52; const DEFAULT_TITLE_IMAGE_MAX_WIDTH = 720; const DEFAULT_TITLE_IMAGE_HEIGHT_RATIO = 0.36; @@ -119,7 +120,9 @@ const resolveAdaptiveFontSize = ( const lengthFactor = Math.sqrt(8 / Math.max(textWeight, 4)); const adaptiveSize = width <= 1 && height <= 1 ? options.fallback : canvasSize * lengthFactor; const boxWidthLimit = - options.boxWidth && options.boxWidth > 0 ? (options.boxWidth / textWeight) * 0.96 : Number.POSITIVE_INFINITY; + options.boxWidth && options.boxWidth > 0 + ? ((options.boxWidth * BLOCK_TITLE_MAX_LINES) / textWeight) * 0.96 + : Number.POSITIVE_INFINITY; const boxHeightLimit = options.boxHeight && options.boxHeight > 0 ? options.boxHeight / Math.max(textWeight, 1) : Number.POSITIVE_INFINITY; return Math.floor(clamp(Math.min(adaptiveSize, boxWidthLimit, boxHeightLimit), minFontSize, maxFontSize)); @@ -404,34 +407,10 @@ export const getLayout = (spec: IStorylineSpec, ctx: LayoutContext): StorylineLa // ===== 文本 / 图像通用工具 ===== -export const buildRichContent = ( - contentText: string[], - spec: IStorylineSpec, - overrides?: { fontSize?: number; lineHeight?: number; fill?: string; align?: 'left' | 'center' | 'right' } -) => { - const fontSize = Number(overrides?.fontSize ?? (spec.content?.style as any)?.fontSize ?? 18); - const lineHeight = Number(overrides?.lineHeight ?? (spec.content?.style as any)?.lineHeight ?? 26); - const fill = overrides?.fill ?? (spec.content?.style as any)?.fill ?? '#596173'; - const align = overrides?.align ?? 'left'; +export const buildPlainContent = (contentText: string[]) => contentText.join('\n'); - return { - type: 'rich' as const, - text: contentText.reduce<{ text: string; fontSize: number; lineHeight: number; fill: string; align: string }[]>( - (result, paragraph, index) => { - const suffix = index === contentText.length - 1 ? '' : '\n'; - result.push({ - text: `${paragraph}${suffix}`, - fontSize, - lineHeight, - fill, - align - }); - return result; - }, - [] - ) - }; -}; +export const getBlockTitleHeight = (lineHeight: number, title?: string) => + title ? lineHeight * BLOCK_TITLE_MAX_LINES : 0; export const omitImageLayoutSpec = (imageSpec: IStorylineSpec['image']) => { if (!imageSpec) { diff --git a/packages/vchart-extension/src/charts/storyline/layouts/default.ts b/packages/vchart-extension/src/charts/storyline/layouts/default.ts index 0fbf0ae890..0cc54d851e 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/default.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/default.ts @@ -9,7 +9,9 @@ import { DEFAULT_IMAGE_WIDTH, DEFAULT_IMAGE_HEIGHT, DEFAULT_IMAGE_GAP, - buildRichContent, + BLOCK_TITLE_MAX_LINES, + buildPlainContent, + getBlockTitleHeight, getImageBackgroundStyle, getImageBox, getLayout, @@ -93,7 +95,7 @@ const getDefaultBlockMetrics = (spec: IStorylineSpec, ctx: LayoutContext, index: ); const titleFontSize = resolveTitleFontSize(spec, ctx, spec.data?.[index]?.title, textBox.width, 18, [8, 28]); const titleLineHeight = resolveAdaptiveLineHeight(titleFontSize, spec.title?.style as any, Math.round(18 * 1.35)); - const titleHeight = spec.data?.[index]?.title ? titleLineHeight : 0; + const titleHeight = getBlockTitleHeight(titleLineHeight, spec.data?.[index]?.title); const contentGap = spec.data?.[index]?.title ? 8 : 0; return { @@ -196,6 +198,11 @@ export const buildDefaultBlockMark = ( text: block.title, maxLineWidth: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).textBox.width, + height: (_datum: unknown, ctx: LayoutContext) => + getDefaultBlockMetrics(spec, ctx, index).titleLineHeight * BLOCK_TITLE_MAX_LINES, + heightLimit: (_datum: unknown, ctx: LayoutContext) => + getDefaultBlockMetrics(spec, ctx, index).titleLineHeight * BLOCK_TITLE_MAX_LINES, + lineClamp: BLOCK_TITLE_MAX_LINES, fontSize: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).titleFontSize, lineHeight: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).titleLineHeight, @@ -206,6 +213,9 @@ export const buildDefaultBlockMark = ( lineJoin: 'round', textAlign: 'left', textBaseline: 'top', + whiteSpace: 'normal', + wordBreak: 'break-word', + ellipsis: '...', ...spec.title?.style } } as ICustomMarkSpec<'text'>) @@ -216,22 +226,22 @@ export const buildDefaultBlockMark = ( name: `storyline-block-content-${index}`, interactive: false, ...spec.content, - textType: 'rich', style: { x: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).textBox.x, y: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).contentBox.y, width: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).textBox.width, - text: buildRichContent(contentText, spec, { - fontSize: 18, - lineHeight: 26, - fill: '#596173' - }), + height: (_datum: unknown, ctx: LayoutContext) => + getDefaultBlockMetrics(spec, ctx, index).contentBox.height, + text: buildPlainContent(contentText), maxLineWidth: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).textBox.width, heightLimit: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).contentBox.height, + fontSize: 16, + lineHeight: 23, textAlign: 'left', textBaseline: 'top', + whiteSpace: 'normal', wordBreak: 'break-word', ellipsis: '...', fill: '#596173', diff --git a/packages/vchart-extension/src/charts/storyline/layouts/ladder.ts b/packages/vchart-extension/src/charts/storyline/layouts/ladder.ts index 6460ecd1e3..1c67e8570f 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/ladder.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/ladder.ts @@ -5,7 +5,9 @@ import { type LayoutContext, DEFAULT_BLOCK_HEIGHT, DEFAULT_IMAGE_GAP, - buildRichContent, + BLOCK_TITLE_MAX_LINES, + buildPlainContent, + getBlockTitleHeight, getImageBackgroundStyle, getImageBox, getLayout, @@ -53,8 +55,8 @@ const LADDER_DIAGONAL_DASH = [12, 8]; const LADDER_BLOCK_IMAGE_SIZE = 100; const LADDER_TITLE_FONT_SIZE = 28; const LADDER_TITLE_LINE_HEIGHT = 26; -const LADDER_CONTENT_FONT_SIZE = 18; -const LADDER_CONTENT_LINE_HEIGHT = 26; +const LADDER_CONTENT_FONT_SIZE = 16; +const LADDER_CONTENT_LINE_HEIGHT = 23; const isDownLadder = (spec: IStorylineSpec) => normalizeLayout(spec.layout).direction === 'down'; @@ -232,7 +234,7 @@ const getLadderBlockMetrics = (spec: IStorylineSpec, ctx: LayoutContext, index: LADDER_TITLE_LINE_HEIGHT, LADDER_TITLE_LINE_HEIGHT / LADDER_TITLE_FONT_SIZE ); - const titleHeight = spec.data?.[index]?.title ? titleLineHeight : 0; + const titleHeight = getBlockTitleHeight(titleLineHeight, spec.data?.[index]?.title); const contentGap = spec.data?.[index]?.title ? 8 : 0; return { block: { width: blockWidth, height: blockHeight }, @@ -345,6 +347,11 @@ export const buildLadderBlockMark = ( y: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).textBox.y, text: block.title, maxLineWidth: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).textBox.width, + height: (_d: unknown, ctx: LayoutContext) => + getLadderBlockMetrics(spec, ctx, index).titleLineHeight * BLOCK_TITLE_MAX_LINES, + heightLimit: (_d: unknown, ctx: LayoutContext) => + getLadderBlockMetrics(spec, ctx, index).titleLineHeight * BLOCK_TITLE_MAX_LINES, + lineClamp: BLOCK_TITLE_MAX_LINES, fontSize: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).titleFontSize, lineHeight: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).titleLineHeight, fontWeight: 'bold', @@ -353,6 +360,9 @@ export const buildLadderBlockMark = ( lineWidth: 5, lineJoin: 'round', textBaseline: 'top', + whiteSpace: 'normal', + wordBreak: 'break-word', + ellipsis: '...', ...spec.title?.style, // 由于 ladder 强制按对角线左右镜像,textAlign 不允许被外层 spec.title.style 覆盖 textAlign: align @@ -365,20 +375,19 @@ export const buildLadderBlockMark = ( name: `storyline-block-content-${index}`, interactive: false, ...spec.content, - textType: 'rich', style: { x: (_d: unknown, ctx: LayoutContext) => getTitleX(ctx), y: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).contentBox.y, width: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).textBox.width, - text: buildRichContent(contentText, spec, { - fontSize: LADDER_CONTENT_FONT_SIZE, - lineHeight: LADDER_CONTENT_LINE_HEIGHT, - align: align as 'left' | 'right' - }), + height: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).contentBox.height, + text: buildPlainContent(contentText), maxLineWidth: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).textBox.width, heightLimit: (_d: unknown, ctx: LayoutContext) => getLadderBlockMetrics(spec, ctx, index).contentBox.height, + fontSize: LADDER_CONTENT_FONT_SIZE, + lineHeight: LADDER_CONTENT_LINE_HEIGHT, textBaseline: 'top', + whiteSpace: 'normal', wordBreak: 'break-word', ellipsis: '...', fill: '#596173', diff --git a/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts b/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts index 0ed2d604dd..b83f26346e 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts @@ -6,10 +6,13 @@ import { type LayoutContext, type StorylinePoint, DEFAULT_BLOCK_HEIGHT, - buildRichContent, + BLOCK_TITLE_MAX_LINES, + buildPlainContent, buildSmoothCurvePath, + getBlockTitleHeight, getImageBackgroundStyle, getLayout, + getRegionGeometry, getThemeColor, normalizePadding, omitImageLayoutSpec, @@ -28,8 +31,8 @@ const LANDSCAPE_TEXT_GAP_FROM_CONNECTOR = 12; // 文字距离引导线的水平 // content 区固定为 10 行,整体 textHeight = titleLineHeight + titleGap + contentLines * contentLineHeight const LANDSCAPE_CONTENT_LINES = 10; const LANDSCAPE_TITLE_LINE_HEIGHT = 34; -const LANDSCAPE_CONTENT_LINE_HEIGHT = 26; -const LANDSCAPE_CONTENT_FONT_SIZE = 18; +const LANDSCAPE_CONTENT_LINE_HEIGHT = 23; +const LANDSCAPE_CONTENT_FONT_SIZE = 16; const LANDSCAPE_TITLE_TO_CONTENT_GAP = 4; /** @@ -123,6 +126,7 @@ const getLandscapeMetrics = ( spec.title?.style as any, LANDSCAPE_TITLE_LINE_HEIGHT ); + const titleHeight = getBlockTitleHeight(titleLineHeight, spec.data?.[index]?.title); const contentFontSize = Number((spec.content?.style as any)?.fontSize ?? LANDSCAPE_CONTENT_FONT_SIZE); const contentLineHeight = Number((spec.content?.style as any)?.lineHeight ?? LANDSCAPE_CONTENT_LINE_HEIGHT); @@ -137,7 +141,7 @@ const getLandscapeMetrics = ( ? Math.max(contentLineHeight * 2, Math.round(canvasHeight / 4)) : LANDSCAPE_CONTENT_LINES * contentLineHeight; const titleToContentGap = LANDSCAPE_TITLE_TO_CONTENT_GAP; - const textHeight = titleLineHeight + titleToContentGap + contentHeight; + const textHeight = titleHeight + titleToContentGap + contentHeight; const textOnTop = index % 2 === 0; @@ -151,19 +155,21 @@ const getLandscapeMetrics = ( const imageX = 0; const connectorX = imageX + blockWidth * LANDSCAPE_CONNECTOR_X_RATIO; const textX = connectorX + LANDSCAPE_TEXT_GAP_FROM_CONNECTOR; - const textWidth = Math.max(blockWidth - (textX - imageX), 0); + const { width: regionWidth } = getRegionGeometry(ctx, spec); + const blockCount = Math.max(spec.data?.length ?? 1, 1); + const textWidth = Math.max(regionWidth / blockCount, 1); if (textOnTop) { const imageY = 0; const textY = imageY - connectorGap - textHeight; const connectorY1 = imageY; - const connectorY2 = textY + titleLineHeight / 2; + const connectorY2 = textY + Math.max(titleHeight, titleLineHeight) / 2; imageBox = { x: imageX, y: imageY, width: blockWidth, height: imageHeight }; textBox = { x: textX, y: textY, width: textWidth, height: textHeight }; contentBox = { x: textX, - y: textY + titleLineHeight + titleToContentGap, + y: textY + titleHeight + titleToContentGap, width: textWidth, height: contentHeight }; @@ -180,7 +186,7 @@ const getLandscapeMetrics = ( textBox = { x: textX, y: textY, width: textWidth, height: textHeight }; contentBox = { x: textX, - y: textY + titleLineHeight + titleToContentGap, + y: textY + titleHeight + titleToContentGap, width: textWidth, height: contentHeight }; @@ -196,7 +202,7 @@ const getLandscapeMetrics = ( contentFontSize, contentLineHeight, contentHeight, - blockWidth, + blockWidth: Math.max(blockWidth, textX + textWidth), imageBox, textBox, contentBox, @@ -322,6 +328,9 @@ export const buildLandscapeBlockMark = ( y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textBox.y, text: block.title, maxLineWidth: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textBox.width, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleLineHeight * BLOCK_TITLE_MAX_LINES, + heightLimit: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleLineHeight * BLOCK_TITLE_MAX_LINES, + lineClamp: BLOCK_TITLE_MAX_LINES, fontSize: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleFontSize, lineHeight: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleLineHeight, fontWeight: 'bold', @@ -331,6 +340,9 @@ export const buildLandscapeBlockMark = ( lineJoin: 'round', textAlign: 'left', textBaseline: 'top', + whiteSpace: 'normal', + wordBreak: 'break-word', + ellipsis: '...', ...spec.title?.style } } as ICustomMarkSpec<'text'>) @@ -341,7 +353,6 @@ export const buildLandscapeBlockMark = ( name: `storyline-block-content-${index}`, interactive: false, ...spec.content, - textType: 'rich', style: { x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.x, y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.y, @@ -349,13 +360,12 @@ export const buildLandscapeBlockMark = ( height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.height, maxLineWidth: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.width, heightLimit: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.height, - text: buildRichContent(contentText, spec, { - fontSize: LANDSCAPE_CONTENT_FONT_SIZE, - lineHeight: LANDSCAPE_CONTENT_LINE_HEIGHT, - fill: '#596173' - }), + text: buildPlainContent(contentText), + fontSize: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentFontSize, + lineHeight: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentLineHeight, textAlign: 'left', textBaseline: 'top', + whiteSpace: 'normal', wordBreak: 'break-word', ellipsis: '...', fill: '#596173', diff --git a/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts b/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts index 96a35921c0..6eba71cfa9 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts @@ -5,7 +5,9 @@ import { type ICustomMarkSpec, type LayoutContext, DEFAULT_BLOCK_HEIGHT, - buildRichContent, + BLOCK_TITLE_MAX_LINES, + buildPlainContent, + getBlockTitleHeight, getImageBackgroundStyle, getLayout, omitImageLayoutSpec, @@ -22,8 +24,8 @@ import { const PORTRAIT_AXIS_WIDTH = 96; const PORTRAIT_AXIS_PADDING = 120; // 中轴上下两端的留白 // marker 时间节点文字的默认样式(fontSize 30、白色字、贴轴对应一侧边缘) -const PORTRAIT_MARKER_FONT_SIZE = 40; -const PORTRAIT_MARKER_LINE_HEIGHT = 28; +const PORTRAIT_MARKER_FONT_SIZE = 34; +const PORTRAIT_MARKER_LINE_HEIGHT = 24; const PORTRAIT_MARKER_AXIS_PADDING = 6; // marker 距离轴边缘的水平内边距 // image 默认尺寸的占比规则(基于 region 平均槽位): // - image 高度 = slotHeight * 0.6 @@ -38,8 +40,8 @@ const PORTRAIT_SHADOW_SCALE = 1; // subImage 与主 image 同尺寸,仅做错 export const PORTRAIT_TEXT_GAP_FROM_IMAGE = 8; export const PORTRAIT_CONTENT_LINES = 3; export const PORTRAIT_TITLE_LINE_HEIGHT = 34; -export const PORTRAIT_CONTENT_LINE_HEIGHT = 26; -const PORTRAIT_CONTENT_FONT_SIZE = 18; +export const PORTRAIT_CONTENT_LINE_HEIGHT = 23; +const PORTRAIT_CONTENT_FONT_SIZE = 16; export const PORTRAIT_TITLE_TO_CONTENT_GAP = 4; export const buildPortraitAxisMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec => { @@ -95,7 +97,8 @@ export const buildPortraitAxisMark = (spec: IStorylineSpec): IExtensionGroupMark ctx, block.marker, axis.height / Math.max(spec.data?.length ?? 1, 1), - PORTRAIT_MARKER_FONT_SIZE + PORTRAIT_MARKER_FONT_SIZE, + [16, 38] ); const markerLineHeight = resolveAdaptiveLineHeight( markerFontSize, @@ -175,11 +178,12 @@ const getPortraitMetrics = ( spec.title?.style as any, PORTRAIT_TITLE_LINE_HEIGHT ); + const titleHeight = getBlockTitleHeight(titleLineHeight, spec.data?.[index]?.title); const minContentHeight = PORTRAIT_CONTENT_LINES * contentLineHeight; // 默认 content 高度 = blockHeight * 0.4 const contentHeight = Math.max(minContentHeight, Math.round(blockHeight * PORTRAIT_CONTENT_HEIGHT_RATIO)); - const textHeight = titleLineHeight + titleToContentGap + contentHeight; + const textHeight = titleHeight + titleToContentGap + contentHeight; const onLeft = index % 2 === 0; @@ -195,7 +199,7 @@ const getPortraitMetrics = ( const contentBox = { x: textX, - y: textY + titleLineHeight + titleToContentGap, + y: textY + titleHeight + titleToContentGap, width: textWidth, height: contentHeight }; @@ -356,6 +360,9 @@ export const buildPortraitBlockMark = ( y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textBox.y, text: block.title, maxLineWidth: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textBox.width, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleLineHeight * BLOCK_TITLE_MAX_LINES, + heightLimit: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleLineHeight * BLOCK_TITLE_MAX_LINES, + lineClamp: BLOCK_TITLE_MAX_LINES, fontSize: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleFontSize, lineHeight: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleLineHeight, fontWeight: 'bold', @@ -365,6 +372,9 @@ export const buildPortraitBlockMark = ( lineJoin: 'round', textAlign: 'left', textBaseline: 'top', + whiteSpace: 'normal', + wordBreak: 'break-word', + ellipsis: '...', ...spec.title?.style } } as ICustomMarkSpec<'text'>) @@ -375,7 +385,6 @@ export const buildPortraitBlockMark = ( name: `storyline-block-content-${index}`, interactive: false, ...spec.content, - textType: 'rich', style: { x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.x, y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.y, @@ -383,13 +392,12 @@ export const buildPortraitBlockMark = ( height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.height, maxLineWidth: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.width, heightLimit: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.height, - text: buildRichContent(contentText, spec, { - fontSize: PORTRAIT_CONTENT_FONT_SIZE, - lineHeight: PORTRAIT_CONTENT_LINE_HEIGHT, - fill: '#596173' - }), + text: buildPlainContent(contentText), + fontSize: PORTRAIT_CONTENT_FONT_SIZE, + lineHeight: PORTRAIT_CONTENT_LINE_HEIGHT, textAlign: 'left', textBaseline: 'top', + whiteSpace: 'normal', wordBreak: 'break-word', ellipsis: '...', fill: '#596173', diff --git a/packages/vchart-extension/src/charts/storyline/layouts/wing.ts b/packages/vchart-extension/src/charts/storyline/layouts/wing.ts index f35a76f33b..9d90fb43a2 100644 --- a/packages/vchart-extension/src/charts/storyline/layouts/wing.ts +++ b/packages/vchart-extension/src/charts/storyline/layouts/wing.ts @@ -5,7 +5,9 @@ import { type ICustomMarkSpec, type LayoutContext, type StorylinePoint, - buildRichContent, + BLOCK_TITLE_MAX_LINES, + buildPlainContent, + getBlockTitleHeight, getImageBackgroundStyle, getChartGeometry, getRegionGeometry, @@ -236,6 +238,7 @@ const getWingBlockMetrics = (spec: IStorylineSpec, ctx: LayoutContext, index: nu WING_TITLE_LINE_HEIGHT, 1.3 ); + const titleHeight = getBlockTitleHeight(titleLineHeight, spec.data?.[index]?.title); const contentFontSize = Number((spec.content?.style as Record)?.fontSize ?? WING_CONTENT_FONT_SIZE); const contentLineHeight = Number( (spec.content?.style as Record)?.lineHeight ?? WING_CONTENT_LINE_HEIGHT @@ -277,7 +280,7 @@ const getWingBlockMetrics = (spec: IStorylineSpec, ctx: LayoutContext, index: nu textBox = { x: textX, y: textY, width: textWidth, height: textHeight }; contentBox = { x: textX, - y: textY + titleLineHeight + titleToContentGap, + y: textY + titleHeight + titleToContentGap, width: textWidth, height: contentHeight }; @@ -298,7 +301,7 @@ const getWingBlockMetrics = (spec: IStorylineSpec, ctx: LayoutContext, index: nu textBox = { x: textX, y: textY, width: textWidth, height: textHeight }; contentBox = { x: textX, - y: textY + titleLineHeight + titleToContentGap, + y: textY + titleHeight + titleToContentGap, width: textWidth, height: contentHeight }; @@ -443,6 +446,11 @@ export const buildWingBlockMark = ( y: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).textBox.y, text: block.title, maxLineWidth: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).textBox.width, + height: (_d: unknown, ctx: LayoutContext) => + getWingBlockMetrics(spec, ctx, index).titleLineHeight * BLOCK_TITLE_MAX_LINES, + heightLimit: (_d: unknown, ctx: LayoutContext) => + getWingBlockMetrics(spec, ctx, index).titleLineHeight * BLOCK_TITLE_MAX_LINES, + lineClamp: BLOCK_TITLE_MAX_LINES, fontSize: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).titleFontSize, lineHeight: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).titleLineHeight, fontWeight: 'bold', @@ -458,6 +466,9 @@ export const buildWingBlockMark = ( return m.onLeft ? 'right' : 'left'; }, textBaseline: 'top', + whiteSpace: 'normal', + wordBreak: 'break-word', + ellipsis: '...', ...spec.title?.style } } as ICustomMarkSpec<'text'>) @@ -469,7 +480,6 @@ export const buildWingBlockMark = ( interactive: false, zIndex: LayoutZIndex.Mark + 10, ...spec.content, - textType: 'rich', style: { x: (_d: unknown, ctx: LayoutContext) => { const m = getWingBlockMetrics(spec, ctx, index); @@ -483,7 +493,7 @@ export const buildWingBlockMark = ( height: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).contentBox.height, maxLineWidth: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).contentBox.width, heightLimit: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).contentBox.height, - text: buildRichContent(contentText, spec), + text: buildPlainContent(contentText), fontSize: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).contentFontSize, lineHeight: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).contentLineHeight, textAlign: (_d: unknown, ctx: LayoutContext) => { @@ -494,6 +504,7 @@ export const buildWingBlockMark = ( return m.onLeft ? 'right' : 'left'; }, textBaseline: 'top', + whiteSpace: 'normal', wordBreak: 'break-word', ellipsis: '...', fill: '#1f2430',