From fbc25a900bf30bf53534fdd7c8618a163aa41559 Mon Sep 17 00:00:00 2001 From: skie1997 Date: Thu, 11 Jun 2026 11:26:16 +0800 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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)