From 2fb9b4f2688f803d56f9a77963602e580ad85911 Mon Sep 17 00:00:00 2001 From: xile611 Date: Fri, 30 Jan 2026 16:56:39 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(vchart-extensions):=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=89=A9=E5=B1=95=E5=9B=BE=E8=A1=A8timeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/specify-rules.mdc | 2 + .../browser/test-page/timeline/group.ts | 154 ++++++ .../browser/test-page/timeline/horizontal.ts | 154 ++++++ .../browser/test-page/timeline/vertical.ts | 158 ++++++ .../src/charts/ranking-bar/ranking-bar.ts | 2 +- .../src/charts/ranking-list/constant.ts | 2 +- .../src/charts/ranking-list/ranking-list.ts | 2 +- .../sequence-scatter-kde-transformer.ts | 6 +- .../sequence-scatter-kde.ts | 2 +- .../sequence-scatter-link-transformer.ts | 9 +- .../sequence-scatter-link.ts | 2 +- .../sequence-scatter-pixel-transformer.ts | 4 +- .../sequence-scatter-pixel.ts | 2 +- .../charts/sequence-scatter-pixel/utils.ts | 2 +- .../src/charts/timeline/index.ts | 4 + .../src/charts/timeline/interface.ts | 13 + .../src/charts/timeline/series/constant.ts | 1 + .../charts/timeline/series/event-series.ts | 495 ++++++++++++++++++ .../src/charts/timeline/series/interface.ts | 38 ++ .../charts/timeline/timeline-transformer.ts | 70 +++ .../src/charts/timeline/timeline.ts | 19 + .../map-label/map-label-transformer.ts | 2 +- packages/vchart-extension/src/index.ts | 1 + packages/vchart/src/vchart-all.ts | 1 + .../checklists/requirements.md | 34 ++ .../contracts/timeline-spec.yaml | 128 +++++ specs/001-add-timeline-chart/data-model.md | 44 ++ specs/001-add-timeline-chart/plan.md | 70 +++ specs/001-add-timeline-chart/quickstart.md | 40 ++ specs/001-add-timeline-chart/research.md | 27 + specs/001-add-timeline-chart/spec.md | 97 ++++ specs/001-add-timeline-chart/tasks.md | 67 +++ 32 files changed, 1634 insertions(+), 18 deletions(-) create mode 100644 packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/group.ts create mode 100644 packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/horizontal.ts create mode 100644 packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/vertical.ts create mode 100644 packages/vchart-extension/src/charts/timeline/index.ts create mode 100644 packages/vchart-extension/src/charts/timeline/interface.ts create mode 100644 packages/vchart-extension/src/charts/timeline/series/constant.ts create mode 100644 packages/vchart-extension/src/charts/timeline/series/event-series.ts create mode 100644 packages/vchart-extension/src/charts/timeline/series/interface.ts create mode 100644 packages/vchart-extension/src/charts/timeline/timeline-transformer.ts create mode 100644 packages/vchart-extension/src/charts/timeline/timeline.ts create mode 100644 specs/001-add-timeline-chart/checklists/requirements.md create mode 100644 specs/001-add-timeline-chart/contracts/timeline-spec.yaml create mode 100644 specs/001-add-timeline-chart/data-model.md create mode 100644 specs/001-add-timeline-chart/plan.md create mode 100644 specs/001-add-timeline-chart/quickstart.md create mode 100644 specs/001-add-timeline-chart/research.md create mode 100644 specs/001-add-timeline-chart/spec.md create mode 100644 specs/001-add-timeline-chart/tasks.md diff --git a/.cursor/rules/specify-rules.mdc b/.cursor/rules/specify-rules.mdc index f120f3e934..0687c8a289 100644 --- a/.cursor/rules/specify-rules.mdc +++ b/.cursor/rules/specify-rules.mdc @@ -3,6 +3,7 @@ Auto-generated from all feature plans. Last updated: 2026-01-15 ## Active Technologies + - TypeScript/React 18 + @visactor/react-vchart, @visactor/vchar (001-react-vchart-demo) - TypeScript 4.9.5 + @visactor/vchart, @visactor/vrender-components (~1.0.37), @visactor/vutils (001-scrollbar-wheel-step) @@ -23,6 +24,7 @@ npm test && npm run lint TypeScript 4.9.5: Follow standard conventions ## Recent Changes + - 001-react-vchart-demo: Added TypeScript/React 18 + @visactor/react-vchart, @visactor/vchar - 001-react-vchart-demo: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A] diff --git a/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/group.ts b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/group.ts new file mode 100644 index 0000000000..3effa0dfeb --- /dev/null +++ b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/group.ts @@ -0,0 +1,154 @@ +import { VChart, type Datum } from '@visactor/vchart'; +import { registerTimelineChart } from '../../../../../src'; +import type { ITimelineChartSpec } from '../../../../../src/charts/timeline'; + +const timelineData = [ + { + id: 'alpha-plan', + group: '研发', + stage: '计划', + time: 1, + event: '需求评审', + detail: 'PRD 评审' + }, + { + id: 'alpha-design', + group: '设计', + stage: '设计', + time: 2, + event: '视觉稿', + detail: '核心页面' + }, + { + id: 'alpha-dev', + group: '研发', + stage: '开发', + time: 3, + event: '功能联调', + detail: '基础功能' + }, + { + id: 'alpha-test', + group: '研发', + stage: '测试', + time: 4, + event: '灰度验证', + detail: '核心流程' + }, + { + id: 'alpha-campaign', + group: '运营', + stage: '运营', + time: 5, + event: '上线预热', + detail: '活动物料' + }, + { + id: 'alpha-launch', + group: '运营', + stage: '发布', + time: 6, + event: '正式上线', + detail: '全量发布' + } +]; + +const getDatumString = (datum: Datum | undefined, key: string) => { + if (!datum || typeof datum !== 'object') { + return ''; + } + const value = (datum as Record)[key]; + return typeof value === 'string' ? value : ''; +}; + +const spec: ITimelineChartSpec = { + type: 'timeline', + name: 'timeline-group', + layoutType: 'horizontal', + padding: { + left: 80, + right: 40, + top: 20, + bottom: 40 + }, + data: [ + { + id: 'timeline-data', + values: timelineData + } + ], + axes: [ + { + orient: 'bottom', + type: 'band', + label: { + formatMethod: (value: string | string[]) => { + const raw = Array.isArray(value) ? value[0] : value; + return raw ? `第${raw}阶段` : ''; + } + } + } + ], + series: [ + { + type: 'event', + dataId: 'timeline-data', + timeField: 'time', + seriesField: 'group', + eventField: 'event', + dotTypeField: 'stage', + title: { + style: { + fill: '#2e2f32' + } + }, + subTitle: { + style: { + fill: '#6b6f76', + dy: 6 + } + } + } + ], + tooltip: { + mark: { + title: { + value: (datum?: Datum) => getDatumString(datum, 'event') + }, + content: [ + { + key: '分组', + value: (datum?: Datum) => getDatumString(datum, 'group') + }, + { + key: '阶段', + value: (datum?: Datum) => getDatumString(datum, 'stage') + }, + { + key: '说明', + value: (datum?: Datum) => getDatumString(datum, 'detail') + } + ] + } + } +}; + +declare global { + interface Window { + vchart?: VChart; + } +} + +const run = () => { + registerTimelineChart(); + const cs = new VChart(spec, { + dom: document.getElementById('chart') as HTMLElement, + onError: err => { + console.error(err); + } + }); + cs.renderSync(); + window.vchart = cs; +}; + +run(); diff --git a/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/horizontal.ts b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/horizontal.ts new file mode 100644 index 0000000000..73f6f6dfd2 --- /dev/null +++ b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/horizontal.ts @@ -0,0 +1,154 @@ +import { VChart, type Datum } from '@visactor/vchart'; +import { registerTimelineChart } from '../../../../../src'; +import type { ITimelineChartSpec } from '../../../../../src/charts/timeline'; + +const timelineData = [ + { + id: 'q1', + quarter: '2023 年 Q1', + title: '项目启动', + detail: '· 团队组建\n· 确定技术选型', + position: 'top', + color: '#8f69ff', + time: 1 + }, + { + id: 'q2', + quarter: '2023 年 Q2', + title: '产品 MVP', + detail: '· 完成核心功能开发\n· 发布内测版本', + position: 'bottom', + color: '#4c7dff', + time: 2 + }, + { + id: 'q3', + quarter: '2023 年 Q3', + title: '市场推广', + detail: '· 线上营销活动\n· 拓展商企合作伙伴', + position: 'top', + color: '#f4c21f', + time: 3 + }, + { + id: 'q4', + quarter: '2023 年 Q4', + title: 'A 轮融资', + detail: '· 完成融资千万\n· 扩大研发团队', + position: 'bottom', + color: '#f39b3d', + time: 4 + } +]; + +const getDatumString = (datum: Datum | undefined, key: string) => { + if (!datum || typeof datum !== 'object') { + return ''; + } + const value = (datum as Record)[key]; + return typeof value === 'string' ? value : ''; +}; + +const spec: ITimelineChartSpec = { + type: 'timeline', + name: 'timeline-horizontal', + layoutType: 'horizontal', + padding: { + left: 80, + right: 80, + top: 120, + bottom: 120 + }, + data: [ + { + id: 'timeline-data', + values: timelineData + } + ], + axes: [ + { + orient: 'bottom', + type: 'band', + label: { + visible: false + }, + tick: { + visible: false + }, + grid: { + visible: false + }, + domainLine: { + visible: false + } + } + ], + series: [ + { + type: 'event', + dataId: 'timeline-data', + timeField: 'time', + eventField: 'title', + subTitleField: 'detail', + dotTypeField: 'quarter', + labelPosition: 'top-bottom', + dot: { + style: { + size: 12, + fill: (datum: Datum) => String((datum as Record).color ?? '') + } + }, + title: { + style: { + fill: '#2e2f32', + fontSize: 14, + fontWeight: 600 + } + }, + subTitle: { + style: { + fill: '#6b6f76', + fontSize: 12, + lineHeight: 18 + } + } + } + ], + tooltip: { + mark: { + title: { + value: (datum?: Datum) => getDatumString(datum, 'title') + }, + content: [ + { + key: '季度', + value: (datum?: Datum) => getDatumString(datum, 'quarter') + }, + { + key: '内容', + value: (datum?: Datum) => getDatumString(datum, 'detail') + } + ] + } + } +}; + +declare global { + interface Window { + vchart?: VChart; + } +} + +const run = () => { + registerTimelineChart(); + const cs = new VChart(spec, { + dom: document.getElementById('chart') as HTMLElement, + onError: err => { + console.error(err); + } + }); + cs.renderSync(); + window.vchart = cs; +}; + +run(); diff --git a/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/vertical.ts b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/vertical.ts new file mode 100644 index 0000000000..899e0cb893 --- /dev/null +++ b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/vertical.ts @@ -0,0 +1,158 @@ +import { VChart, type Datum } from '@visactor/vchart'; +import { registerTimelineChart } from '../../../../../src'; +import type { ITimelineChartSpec } from '../../../../../src/charts/timeline'; + +const timelineData = [ + { + id: 'alpha-kickoff', + project: 'Project Alpha', + group: '研发', + stage: '立项', + time: new Date('2024-01-02').getTime(), + event: '立项', + detail: '需求确认' + }, + { + id: 'alpha-design', + project: 'Project Alpha', + group: '研发', + stage: '设计', + time: new Date('2024-02-01').getTime(), + event: '设计', + detail: '视觉/交互' + }, + { + id: 'alpha-release', + project: 'Project Alpha', + group: '研发', + stage: '发布', + time: new Date('2024-03-01').getTime(), + event: '发布', + detail: '对外上线' + }, + { + id: 'beta-warmup', + project: 'Project Beta', + group: '运营', + stage: '预热', + time: new Date('2024-01-06').getTime(), + event: '预热', + detail: '内容投放' + }, + { + id: 'beta-campaign', + project: 'Project Beta', + group: '运营', + stage: '活动', + time: new Date('2024-02-05').getTime(), + event: '活动', + detail: '主站曝光' + }, + { + id: 'beta-review', + project: 'Project Beta', + group: '运营', + stage: '复盘', + time: new Date('2024-03-05').getTime(), + event: '复盘', + detail: '指标总结' + } +]; + +const getDatumString = (datum: Datum | undefined, key: string) => { + if (!datum || typeof datum !== 'object') { + return ''; + } + const value = (datum as Record)[key]; + return typeof value === 'string' ? value : ''; +}; + +const spec: ITimelineChartSpec = { + type: 'timeline', + name: 'timeline-vertical', + layoutType: 'vertical', + data: [ + { + id: 'timeline-data', + values: timelineData + } + ], + axes: [ + { + orient: 'left', + type: 'time', + label: { + // formatMethod: (value: string | string[]) => { + // const raw = Array.isArray(value) ? value[0] : value; + // const timeValue = Number(raw); + // if (Number.isFinite(timeValue)) { + // return new Date(timeValue).toLocaleDateString(); + // } + // return raw ? String(raw) : ''; + // } + } + } + ], + series: [ + { + type: 'event', + dataId: 'timeline-data', + timeField: 'time', + seriesField: 'group', + eventField: 'event', + dotTypeField: 'stage', + title: { + style: { + fill: '#2e2f32' + } + }, + subTitle: { + style: { + fill: '#6b6f76', + dy: 6 + } + } + } + ], + tooltip: { + mark: { + title: { + value: (datum?: Datum) => getDatumString(datum, 'event') + }, + content: [ + { + key: '项目', + value: (datum?: Datum) => getDatumString(datum, 'project') + }, + { + key: '阶段', + value: (datum?: Datum) => getDatumString(datum, 'stage') + }, + { + key: '说明', + value: (datum?: Datum) => getDatumString(datum, 'detail') + } + ] + } + } +}; + +declare global { + interface Window { + vchart?: VChart; + } +} + +const run = () => { + registerTimelineChart(); + const cs = new VChart(spec, { + dom: document.getElementById('chart') as HTMLElement, + onError: err => { + console.error(err); + } + }); + cs.renderSync(); + window.vchart = cs; +}; + +run(); diff --git a/packages/vchart-extension/src/charts/ranking-bar/ranking-bar.ts b/packages/vchart-extension/src/charts/ranking-bar/ranking-bar.ts index fe7e3ed08d..e7447658b8 100644 --- a/packages/vchart-extension/src/charts/ranking-bar/ranking-bar.ts +++ b/packages/vchart-extension/src/charts/ranking-bar/ranking-bar.ts @@ -1,4 +1,4 @@ -import { IRankingBarSpec } from './interface'; +import type { IRankingBarSpec } from './interface'; import { VChart, BaseChart } from '@visactor/vchart'; import { RankingBarChartSpecTransformer } from './ranking-bar-transformer'; diff --git a/packages/vchart-extension/src/charts/ranking-list/constant.ts b/packages/vchart-extension/src/charts/ranking-list/constant.ts index eaf063834e..797df516bd 100644 --- a/packages/vchart-extension/src/charts/ranking-list/constant.ts +++ b/packages/vchart-extension/src/charts/ranking-list/constant.ts @@ -1,4 +1,4 @@ -import { IRankingListSpec } from './interface'; +import type { IRankingListSpec } from './interface'; const cornerRadius = 5; const animationDuration = 1000; diff --git a/packages/vchart-extension/src/charts/ranking-list/ranking-list.ts b/packages/vchart-extension/src/charts/ranking-list/ranking-list.ts index 5b9c9020b2..272046cc0c 100644 --- a/packages/vchart-extension/src/charts/ranking-list/ranking-list.ts +++ b/packages/vchart-extension/src/charts/ranking-list/ranking-list.ts @@ -1,4 +1,4 @@ -import { IRankingListSpec } from './interface'; +import type { IRankingListSpec } from './interface'; import { VChart, BaseChart, BarChart } from '@visactor/vchart'; import { RankingListChartSpecTransformer } from './ranking-list-transformer'; diff --git a/packages/vchart-extension/src/charts/sequence-scatter-kde/sequence-scatter-kde-transformer.ts b/packages/vchart-extension/src/charts/sequence-scatter-kde/sequence-scatter-kde-transformer.ts index 5c4c1e77cf..644e3012d5 100644 --- a/packages/vchart-extension/src/charts/sequence-scatter-kde/sequence-scatter-kde-transformer.ts +++ b/packages/vchart-extension/src/charts/sequence-scatter-kde/sequence-scatter-kde-transformer.ts @@ -1,7 +1,7 @@ -import { Datum } from '@visactor/vchart/src/typings'; +import type { Datum } from '@visactor/vchart/src/typings'; import type { ISequenceScatterKDESpec } from './interface'; import { CommonChartSpecTransformer } from '@visactor/vchart'; -import { Point } from './interface'; +import type { Point } from './interface'; import { calculateKDE } from './utils'; const DATA_KEY = 'dataKey'; @@ -83,7 +83,7 @@ export class SequenceScatterKDEChartSpecTransformer extends CommonChartSpecTrans type: 'text', dataIndex: 1, style: { - text: (datum: Datum) => datum['iter'], + text: (datum: Datum) => datum.iter, x: 10, y: () => 10, textBaseline: 'top', diff --git a/packages/vchart-extension/src/charts/sequence-scatter-kde/sequence-scatter-kde.ts b/packages/vchart-extension/src/charts/sequence-scatter-kde/sequence-scatter-kde.ts index c72061b5b2..0b02909198 100644 --- a/packages/vchart-extension/src/charts/sequence-scatter-kde/sequence-scatter-kde.ts +++ b/packages/vchart-extension/src/charts/sequence-scatter-kde/sequence-scatter-kde.ts @@ -1,4 +1,4 @@ -import { ISequenceScatterKDESpec } from './interface'; +import type { ISequenceScatterKDESpec } from './interface'; import { VChart, BaseChart, ScatterChart } from '@visactor/vchart'; import { SequenceScatterKDEChartSpecTransformer } from './sequence-scatter-kde-transformer'; diff --git a/packages/vchart-extension/src/charts/sequence-scatter-link/sequence-scatter-link-transformer.ts b/packages/vchart-extension/src/charts/sequence-scatter-link/sequence-scatter-link-transformer.ts index a40644337b..95f2be3f76 100644 --- a/packages/vchart-extension/src/charts/sequence-scatter-link/sequence-scatter-link-transformer.ts +++ b/packages/vchart-extension/src/charts/sequence-scatter-link/sequence-scatter-link-transformer.ts @@ -1,4 +1,4 @@ -import { Datum } from '@visactor/vchart/src/typings'; +import type { Datum } from '@visactor/vchart/src/typings'; import type { ISequenceScatterLinkSpec } from './interface'; import { CommonChartSpecTransformer } from '@visactor/vchart'; @@ -11,7 +11,7 @@ export class SequenceScatterLinkChartSpecTransformer extends CommonChartSpecTran transformSpec(spec: any): void { super.transformSpec(spec); const dataSpecs = processSequenceData(spec as unknown as ISequenceScatterLinkSpec); - const showTooltip = spec.taskType === 'neighborhood' ? false : true; + const showTooltip = spec.taskType !== 'neighborhood'; spec.type = 'common'; @@ -76,9 +76,8 @@ export class SequenceScatterLinkChartSpecTransformer extends CommonChartSpecTran lineDash: (datum: { type: string }) => { if (datum.type === 'same_type') { return [0, 0]; - } else { - return [3, 2]; } + return [3, 2]; }, lineWidth: 0.8, strokeOpacity: 0.6 @@ -122,7 +121,7 @@ export class SequenceScatterLinkChartSpecTransformer extends CommonChartSpecTran visible: true, style: { visible: () => { - return spec.taskType == 'neighborhood'; + return spec.taskType === 'neighborhood'; }, type: 'text', fontFamily: 'Console', diff --git a/packages/vchart-extension/src/charts/sequence-scatter-link/sequence-scatter-link.ts b/packages/vchart-extension/src/charts/sequence-scatter-link/sequence-scatter-link.ts index f65c7b5505..3fd1626e90 100644 --- a/packages/vchart-extension/src/charts/sequence-scatter-link/sequence-scatter-link.ts +++ b/packages/vchart-extension/src/charts/sequence-scatter-link/sequence-scatter-link.ts @@ -1,4 +1,4 @@ -import { ISequenceScatterLinkSpec } from './interface'; +import type { ISequenceScatterLinkSpec } from './interface'; import { VChart, BaseChart, ScatterChart } from '@visactor/vchart'; import { SequenceScatterLinkChartSpecTransformer } from './sequence-scatter-link-transformer'; diff --git a/packages/vchart-extension/src/charts/sequence-scatter-pixel/sequence-scatter-pixel-transformer.ts b/packages/vchart-extension/src/charts/sequence-scatter-pixel/sequence-scatter-pixel-transformer.ts index cb97d6c0f3..c0c1246d36 100644 --- a/packages/vchart-extension/src/charts/sequence-scatter-pixel/sequence-scatter-pixel-transformer.ts +++ b/packages/vchart-extension/src/charts/sequence-scatter-pixel/sequence-scatter-pixel-transformer.ts @@ -1,4 +1,4 @@ -import { Datum } from '@visactor/vchart/src/typings'; +import type { Datum } from '@visactor/vchart/src/typings'; import type { ISequenceScatterPixelSpec } from './interface'; import { CommonChartSpecTransformer } from '@visactor/vchart'; import { processSequenceData } from './utils'; @@ -71,7 +71,7 @@ export class SequenceScatterPixelChartSpecTransformer extends CommonChartSpecTra type: 'text', dataIndex: 1, style: { - text: (datum: Datum) => datum['inter'], + text: (datum: Datum) => datum.inter, x: 50, y: () => 10, textBaseline: 'top', diff --git a/packages/vchart-extension/src/charts/sequence-scatter-pixel/sequence-scatter-pixel.ts b/packages/vchart-extension/src/charts/sequence-scatter-pixel/sequence-scatter-pixel.ts index 00697a4203..234d512ee0 100644 --- a/packages/vchart-extension/src/charts/sequence-scatter-pixel/sequence-scatter-pixel.ts +++ b/packages/vchart-extension/src/charts/sequence-scatter-pixel/sequence-scatter-pixel.ts @@ -1,4 +1,4 @@ -import { ISequenceScatterPixelSpec } from './interface'; +import type { ISequenceScatterPixelSpec } from './interface'; import { VChart, BaseChart, ScatterChart } from '@visactor/vchart'; import { SequenceScatterPixelChartSpecTransformer } from './sequence-scatter-pixel-transformer'; diff --git a/packages/vchart-extension/src/charts/sequence-scatter-pixel/utils.ts b/packages/vchart-extension/src/charts/sequence-scatter-pixel/utils.ts index 1b316088c1..313a45a7a2 100644 --- a/packages/vchart-extension/src/charts/sequence-scatter-pixel/utils.ts +++ b/packages/vchart-extension/src/charts/sequence-scatter-pixel/utils.ts @@ -1,4 +1,4 @@ -import { ISequenceScatterPixelSpec } from './interface'; +import type { ISequenceScatterPixelSpec } from './interface'; import { BACKGROUND_KEY, DATA_KEY } from './constant'; // 将RGB三元组数组转换为Canvas可用的imageData diff --git a/packages/vchart-extension/src/charts/timeline/index.ts b/packages/vchart-extension/src/charts/timeline/index.ts new file mode 100644 index 0000000000..a4bd6f828d --- /dev/null +++ b/packages/vchart-extension/src/charts/timeline/index.ts @@ -0,0 +1,4 @@ +export * from './interface'; +export * from './timeline'; +export * from './series/interface'; +export * from './series/event-series'; diff --git a/packages/vchart-extension/src/charts/timeline/interface.ts b/packages/vchart-extension/src/charts/timeline/interface.ts new file mode 100644 index 0000000000..544e149993 --- /dev/null +++ b/packages/vchart-extension/src/charts/timeline/interface.ts @@ -0,0 +1,13 @@ +import type { IChartExtendsSeriesSpec, IChartSpec, ICartesianAxisSpec } from '@visactor/vchart'; +import type { IEventSeriesSpec } from './series/interface'; + +export type TimelineLayoutType = 'horizontal' | 'vertical' | 'radial' | 's-curve'; + +export interface ITimelineChartSpec + extends IChartSpec, + Omit, 'type' | 'title'> { + type: 'timeline'; + layoutType?: TimelineLayoutType; + series?: IEventSeriesSpec[]; + axes?: ICartesianAxisSpec[]; +} diff --git a/packages/vchart-extension/src/charts/timeline/series/constant.ts b/packages/vchart-extension/src/charts/timeline/series/constant.ts new file mode 100644 index 0000000000..fa45bedd0b --- /dev/null +++ b/packages/vchart-extension/src/charts/timeline/series/constant.ts @@ -0,0 +1 @@ +export const EVENT_SERIES_TYPE = 'event'; diff --git a/packages/vchart-extension/src/charts/timeline/series/event-series.ts b/packages/vchart-extension/src/charts/timeline/series/event-series.ts new file mode 100644 index 0000000000..31126d68ef --- /dev/null +++ b/packages/vchart-extension/src/charts/timeline/series/event-series.ts @@ -0,0 +1,495 @@ +import type { StringOrNumber } from '@visactor/vchart'; +import { + AttributeLevel, + BaseSeries, + DEFAULT_DATA_KEY, + Factory, + MarkTypeEnum, + SeriesMarkNameEnum, + baseSeriesMark, + registerSymbolMark, + registerTextMark, + registerLineMark, + STATE_VALUE_ENUM, + type Datum, + type IMark, + type IPoint, + type SeriesMarkMap, + BaseSeriesSpecTransformer +} from '@visactor/vchart'; +import { EVENT_SERIES_TYPE } from './constant'; +import type { IEventSeriesSpec, LabelPosition } from './interface'; + +type AxisHelper = { + isContinuous?: boolean; + getSpec?: () => { type?: string }; + dataToPosition?: (values: any[], cfg?: { bandPosition?: number }) => number; + valueToPosition?: (value: any) => number; +}; + +/** 默认 title 字体大小 */ +const DEFAULT_TITLE_FONT_SIZE = 14; +/** 默认 dot 和 label 之间的间距 */ +const DEFAULT_DOT_LABEL_GAP = 6; +/** 默认 title 和 subTitle 之间的间距 */ +const DEFAULT_TITLE_SUBTITLE_GAP = 4; + +const eventSeriesMark: SeriesMarkMap = { + ...baseSeriesMark, + [SeriesMarkNameEnum.line]: { name: SeriesMarkNameEnum.line, type: MarkTypeEnum.line }, + [SeriesMarkNameEnum.dot]: { name: SeriesMarkNameEnum.dot, type: MarkTypeEnum.symbol }, + [SeriesMarkNameEnum.title]: { name: SeriesMarkNameEnum.title, type: MarkTypeEnum.text }, + [SeriesMarkNameEnum.subTitle]: { name: SeriesMarkNameEnum.subTitle, type: MarkTypeEnum.text } +}; + +export class EventSeries extends BaseSeries { + static readonly type: string = EVENT_SERIES_TYPE; + type = EVENT_SERIES_TYPE as any; + + static readonly mark: SeriesMarkMap = eventSeriesMark; + static readonly transformerConstructor = BaseSeriesSpecTransformer as any; + readonly transformerConstructor = BaseSeriesSpecTransformer as any; + + protected declare _spec: T; + + private _dotMark?: IMark; + private _titleMark?: IMark; + private _subTitleMark?: IMark; + private _axisLineMark?: IMark; + + private _timeField?: string; + private _eventField?: string; + private _subTitleField?: string; + private _layoutType?: T['layoutType']; + private _labelPosition?: LabelPosition; + private _xAxisHelper?: AxisHelper; + private _yAxisHelper?: AxisHelper; + private _scaleX?: unknown; + private _scaleY?: unknown; + + setAttrFromSpec(): void { + super.setAttrFromSpec(); + this.setSeriesField(this._spec.seriesField); + this._timeField = this._spec.timeField as string | undefined; + this._eventField = this._spec.eventField; + this._subTitleField = this._spec.subTitleField; + this._layoutType = this._spec.layoutType; + this._labelPosition = this._spec.labelPosition; + } + + getDimensionField(): string[] { + return this._timeField ? [this._timeField] : []; + } + + getMeasureField(): string[] { + return []; + } + + initMark(): void { + this._axisLineMark = this._createMark(EventSeries.mark.line, { + isSeriesMark: true, + groupKey: this._seriesField + }); + + this._dotMark = this._createMark(EventSeries.mark.dot, { + isSeriesMark: true + }); + + this._titleMark = this._createMark(EventSeries.mark.title); + + this._subTitleMark = this._createMark(EventSeries.mark.subTitle); + } + + initMarkStyle(): void { + if (this._axisLineMark) { + this.setMarkStyle( + this._axisLineMark, + { + points: this._getAxisPoints.bind(this), + visible: this._isFirstDataInGroup.bind(this), + stroke: '#c0c3c7', + lineWidth: 1 + }, + STATE_VALUE_ENUM.STATE_NORMAL, + AttributeLevel.Series + ); + } + + const dotSpec = this._spec.dot as { size?: number; style?: { size?: number; fill?: unknown } } | undefined; + const dotSize = + typeof dotSpec?.style?.size === 'number' + ? dotSpec.style.size + : typeof dotSpec?.size === 'number' + ? dotSpec.size + : 8; + + const titleStyle = this._spec.title?.style ?? {}; + const subTitleStyle = this._spec.subTitle?.style ?? {}; + + // 获取 title 字体大小,用于计算 subTitle 的位置 + const titleFontSize = typeof titleStyle.fontSize === 'number' ? titleStyle.fontSize : DEFAULT_TITLE_FONT_SIZE; + + // dot 和 label 之间的间距 + const labelOffset = dotSize / 2 + DEFAULT_DOT_LABEL_GAP; + + if (this._dotMark) { + this.setMarkStyle( + this._dotMark, + { + x: (datum: Datum) => this._getPoint(datum).x, + y: (datum: Datum) => this._getPoint(datum).y, + size: dotSize, + fill: dotSpec?.style?.fill ?? this.getColorAttribute() + }, + STATE_VALUE_ENUM.STATE_NORMAL, + AttributeLevel.Series + ); + } + + if (this._titleMark) { + this.setMarkStyle( + this._titleMark, + { + fontSize: DEFAULT_TITLE_FONT_SIZE, + ...titleStyle, + x: (datum: Datum) => this._getTitlePosition(datum, labelOffset).x, + y: (datum: Datum) => this._getTitlePosition(datum, labelOffset).y, + textAlign: (datum: Datum) => this._getLabelTextAlign(datum), + textBaseline: (datum: Datum) => this._getLabelTextBaseline(datum), + text: (datum: Datum) => this._getDatumString(datum, this._eventField) + }, + STATE_VALUE_ENUM.STATE_NORMAL, + AttributeLevel.Series + ); + } + + if (this._subTitleMark) { + // subTitle 位置 = title 位置 + title 字体大小 + 间距 + const subTitleOffset = labelOffset + titleFontSize + DEFAULT_TITLE_SUBTITLE_GAP; + + this.setMarkStyle( + this._subTitleMark, + { + ...subTitleStyle, + x: (datum: Datum) => this._getSubTitlePosition(datum, subTitleOffset).x, + y: (datum: Datum) => this._getSubTitlePosition(datum, subTitleOffset).y, + textAlign: (datum: Datum) => this._getLabelTextAlign(datum), + textBaseline: (datum: Datum) => this._getLabelTextBaseline(datum), + text: (datum: Datum) => this._getDatumString(datum, this._subTitleField) + }, + STATE_VALUE_ENUM.STATE_NORMAL, + AttributeLevel.Series + ); + } + } + + /** + * 根据数据索引判断标签应该放在哪一侧 + * @returns 'primary' 表示 top/left,'secondary' 表示 bottom/right + */ + private _getLabelSide(datum: Datum): 'primary' | 'secondary' { + const data = this._getViewDataList(); + const index = data.indexOf(datum); + const position = this._labelPosition; + + if (this._layoutType === 'vertical') { + // vertical 布局: left/right + switch (position) { + case 'left': + return 'primary'; + case 'right': + return 'secondary'; + case 'left-right': + return index % 2 === 0 ? 'primary' : 'secondary'; + case 'right-left': + return index % 2 === 0 ? 'secondary' : 'primary'; + default: + return 'primary'; // 默认 left + } + } else { + // horizontal 布局: top/bottom + switch (position) { + case 'top': + return 'primary'; + case 'bottom': + return 'secondary'; + case 'top-bottom': + return index % 2 === 0 ? 'primary' : 'secondary'; + case 'bottom-top': + return index % 2 === 0 ? 'secondary' : 'primary'; + default: + return 'primary'; // 默认 top + } + } + } + + /** + * 获取标签的文本对齐方式 + */ + private _getLabelTextAlign(datum: Datum): 'left' | 'right' | 'center' { + if (this._layoutType === 'vertical') { + const side = this._getLabelSide(datum); + return side === 'primary' ? 'right' : 'left'; + } + return 'center'; + } + + /** + * 获取标签的文本基线 + */ + private _getLabelTextBaseline(datum: Datum): 'top' | 'bottom' | 'middle' { + if (this._layoutType === 'vertical') { + return 'middle'; + } + const side = this._getLabelSide(datum); + return side === 'primary' ? 'bottom' : 'top'; + } + + /** + * 获取 title 的位置 + */ + private _getTitlePosition(datum: Datum, offset: number): IPoint { + const point = this._getPoint(datum); + const side = this._getLabelSide(datum); + + if (this._layoutType === 'vertical') { + // vertical: left/right + return { + x: side === 'primary' ? point.x - offset : point.x + offset, + y: point.y + }; + } + // horizontal: top/bottom + return { + x: point.x, + y: side === 'primary' ? point.y - offset : point.y + offset + }; + } + + /** + * 获取 subTitle 的位置 + */ + private _getSubTitlePosition(datum: Datum, offset: number): IPoint { + const point = this._getPoint(datum); + const side = this._getLabelSide(datum); + + if (this._layoutType === 'vertical') { + // vertical: left/right + return { + x: side === 'primary' ? point.x - offset : point.x + offset, + y: point.y + }; + } + // horizontal: top/bottom + return { + x: point.x, + y: side === 'primary' ? point.y - offset : point.y + offset + }; + } + + private _getViewDataList(): Datum[] { + return this.getViewData()?.latestData ?? []; + } + + private _getPoint(datum: Datum): IPoint { + const rect = this._region.getLayoutRect(); + const data = this._getViewDataList(); + const index = data.indexOf(datum); + const count = Math.max(1, data.length); + + // 计算时间方向的位置 (水平布局时是 x,垂直布局时是 y) + let timePercent: number; + if (this._timeField && datum) { + const timeValue = (datum as Record)[this._timeField]; + if (typeof timeValue === 'number') { + // 获取所有时间值来计算范围 + const timeValues = data + .map(d => (d as Record)[this._timeField!] as number) + .filter(v => typeof v === 'number'); + const minTime = Math.min(...timeValues); + const maxTime = Math.max(...timeValues); + + if (maxTime !== minTime) { + timePercent = (timeValue - minTime) / (maxTime - minTime); + } else { + timePercent = 0.5; + } + } else { + timePercent = (index + 0.5) / count; + } + } else { + timePercent = (index + 0.5) / count; + } + + // 计算分类方向的位置(如果有 seriesField) + let categoryPercent = 0.5; + if (this._seriesField && datum) { + const categoryValue = (datum as Record)[this._seriesField]; + const uniqueCategories = Array.from(new Set(data.map(d => (d as Record)[this._seriesField!]))); + const categoryIndex = uniqueCategories.indexOf(categoryValue); + const categoryCount = Math.max(1, uniqueCategories.length); + categoryPercent = (categoryIndex + 0.5) / categoryCount; + } + + if (this._layoutType === 'vertical') { + return { + x: rect.width * categoryPercent, + y: rect.height * timePercent + }; + } + + return { + x: rect.width * timePercent, + y: rect.height * categoryPercent + }; + } + + private _getAxisPoints(datum: Datum): IPoint[] { + const rect = this._region.getLayoutRect(); + const data = this._getViewDataList(); + + // 计算分类方向的位置 + let categoryPercent = 0.5; + if (this._seriesField && datum) { + const categoryValue = (datum as Record)[this._seriesField]; + const uniqueCategories = Array.from(new Set(data.map(d => (d as Record)[this._seriesField!]))); + const categoryIndex = uniqueCategories.indexOf(categoryValue); + const categoryCount = Math.max(1, uniqueCategories.length); + categoryPercent = (categoryIndex + 0.5) / categoryCount; + } + + if (this._layoutType === 'vertical') { + const x = rect.width * categoryPercent; + return [ + { x, y: 0 }, + { x, y: rect.height } + ]; + } + + const y = rect.height * categoryPercent; + return [ + { x: 0, y }, + { x: rect.width, y } + ]; + } + + private _isFirstDataInGroup(datum: Datum): boolean { + if (!this._seriesField) { + const data = this._getViewDataList(); + return data.indexOf(datum) === 0; + } + + const data = this._getViewDataList(); + const categoryValue = (datum as Record)[this._seriesField]; + + // 找到该分类中的第一条数据 + const firstInGroup = data.find(d => (d as Record)[this._seriesField!] === categoryValue); + return datum === firstInGroup; + } + + private _getDatumString(datum: Datum | undefined, field?: string): string { + if (!datum || !field) { + return ''; + } + const value = (datum as Record)[field]; + return typeof value === 'string' ? value : value == null ? '' : String(value); + } + + getStatisticFields(): { key: string; operations: Array<'max' | 'min' | 'values'>; customize?: any[] }[] { + const fields: { key: string; operations: Array<'max' | 'min' | 'values'>; customize?: any[] }[] = []; + + if (this._timeField) { + fields.push({ key: this._timeField, operations: ['max', 'min', 'values'] }); + } + + if (this._seriesField) { + fields.push({ key: this._seriesField, operations: ['values'] }); + } + + return fields; + } + + getGroupFields(): string[] { + return this._seriesField ? [this._seriesField] : []; + } + + getXAxisHelper(): AxisHelper | undefined { + return this._xAxisHelper; + } + + setXAxisHelper(h: AxisHelper) { + this._xAxisHelper = h; + this.onMarkPositionUpdate(); + } + + get scaleX(): unknown { + return this._scaleX; + } + + setScaleX(s: unknown) { + this._scaleX = s; + } + + getYAxisHelper(): AxisHelper | undefined { + return this._yAxisHelper; + } + + setYAxisHelper(h: AxisHelper) { + this._yAxisHelper = h; + this.onMarkPositionUpdate(); + } + + get scaleY(): unknown { + return this._scaleY; + } + + setScaleY(s: unknown) { + this._scaleY = s; + } + + dataToPosition(data: Datum): IPoint { + return this._getPoint(data); + } + + dataToPositionX(data: Datum): number { + return this._getPoint(data).x; + } + + dataToPositionY(data: Datum): number { + return this._getPoint(data).y; + } + + valueToPosition(timeValue: StringOrNumber, eventValue?: StringOrNumber): IPoint { + if (timeValue && typeof timeValue === 'object') { + return this.dataToPosition(timeValue as Datum); + } + + const mockDatum: Record = {}; + if (this._timeField) { + mockDatum[this._timeField] = timeValue; + } + if (this._eventField && eventValue !== undefined) { + mockDatum[this._eventField] = eventValue; + } + return this._getPoint(mockDatum as Datum); + } + + getStackGroupFields(): string[] { + return []; + } + + getStackValueField(): string | undefined { + return undefined; + } + + getActiveMarks(): IMark[] { + return [this._axisLineMark, this._dotMark, this._titleMark, this._subTitleMark].filter(Boolean) as IMark[]; + } +} + +export const registerEventSeries = () => { + registerSymbolMark(); + registerTextMark(); + registerLineMark(); + Factory.registerSeries(EventSeries.type, EventSeries); +}; diff --git a/packages/vchart-extension/src/charts/timeline/series/interface.ts b/packages/vchart-extension/src/charts/timeline/series/interface.ts new file mode 100644 index 0000000000..117f34ccab --- /dev/null +++ b/packages/vchart-extension/src/charts/timeline/series/interface.ts @@ -0,0 +1,38 @@ +import type { + ICartesianSeriesSpec, + ICartesianSeriesTheme, + IMarkSpec, + ISymbolMarkSpec, + ITextMarkSpec, + ILineMarkSpec, + ISeriesSpec +} from '@visactor/vchart'; +import type { TimelineLayoutType } from '../interface'; + +/** horizontal 布局时的标题位置 */ +export type HorizontalLabelPosition = 'top' | 'bottom' | 'top-bottom' | 'bottom-top'; + +/** vertical 布局时的标题位置 */ +export type VerticalLabelPosition = 'left' | 'right' | 'left-right' | 'right-left'; + +/** 标题位置配置 */ +export type LabelPosition = HorizontalLabelPosition | VerticalLabelPosition; + +export interface IEventSeriesSpec extends ISeriesSpec { + type: 'event'; + timeField?: string; + eventField?: string; + subTitleField?: string; + seriesField?: string; + dotTypeField?: string; + layoutType?: TimelineLayoutType; + /** 标题和副标题的位置 */ + labelPosition?: LabelPosition; + name?: string; + dot?: IMarkSpec; + title?: IMarkSpec; + subTitle?: IMarkSpec; + line?: IMarkSpec; +} + +export type IEventSeriesTheme = ICartesianSeriesTheme; diff --git a/packages/vchart-extension/src/charts/timeline/timeline-transformer.ts b/packages/vchart-extension/src/charts/timeline/timeline-transformer.ts new file mode 100644 index 0000000000..7037a496af --- /dev/null +++ b/packages/vchart-extension/src/charts/timeline/timeline-transformer.ts @@ -0,0 +1,70 @@ +import { BaseChartSpecTransformer } from '@visactor/vchart'; +import type { ICartesianAxisSpec, ICartesianLinearAxisSpec } from '@visactor/vchart'; +import type { ITimelineChartSpec } from './interface'; + +export class TimelineChartSpecTransformer< + T extends ITimelineChartSpec = ITimelineChartSpec +> extends BaseChartSpecTransformer { + protected _getDefaultSeriesSpec(spec: T): any { + return super._getDefaultSeriesSpec(spec, [ + 'timeField', + 'eventField', + 'seriesField', + 'dotTypeField', + 'titleField', + 'subTitleField', + 'dot', + 'title', + 'subTitle', + 'symbol' + ]); + } + + transformSpec(spec: T): void { + super.transformSpec(spec); + this.transformSeriesSpec(spec); + const rawAxis = spec.axes?.[0]; + const axisType = rawAxis?.type ?? 'band'; + const axisOrient = rawAxis?.orient; + let layoutType = spec.layoutType; + if (!layoutType) { + if (axisOrient === 'left' || axisOrient === 'right') { + layoutType = 'vertical'; + } else if (axisOrient === 'bottom' || axisOrient === 'top') { + layoutType = 'horizontal'; + } + } + if (!layoutType) { + layoutType = 'horizontal'; + } + spec.layoutType = layoutType; + + const defaultOrient = layoutType === 'vertical' ? 'left' : 'bottom'; + const allowedOrients = layoutType === 'vertical' ? ['left', 'right'] : ['bottom', 'right']; + const orientNormalized: ICartesianAxisSpec['orient'] = allowedOrients.includes(axisOrient ?? '') + ? (axisOrient as ICartesianAxisSpec['orient']) + : (defaultOrient as ICartesianAxisSpec['orient']); + const typeNormalized: ICartesianAxisSpec['type'] = + axisType === 'linear' || axisType === 'time' || axisType === 'band' ? (axisType as any) : 'band'; + const baseAxis: ICartesianAxisSpec = { + ...((rawAxis ?? {}) as ICartesianAxisSpec), + orient: orientNormalized, + type: typeNormalized + }; + if (baseAxis.type === 'linear') { + const linearAxis: ICartesianLinearAxisSpec = { + ...(baseAxis as ICartesianLinearAxisSpec), + zero: (rawAxis as ICartesianLinearAxisSpec)?.zero ?? false + }; + spec.axes = [linearAxis]; + } else { + spec.axes = [baseAxis]; + } + + spec.series?.forEach(seriesSpec => { + if (!seriesSpec.layoutType && spec.layoutType) { + seriesSpec.layoutType = spec.layoutType; + } + }); + } +} diff --git a/packages/vchart-extension/src/charts/timeline/timeline.ts b/packages/vchart-extension/src/charts/timeline/timeline.ts new file mode 100644 index 0000000000..445230c1ea --- /dev/null +++ b/packages/vchart-extension/src/charts/timeline/timeline.ts @@ -0,0 +1,19 @@ +import { TimelineChartSpecTransformer } from './timeline-transformer'; +import type { ITimelineChartSpec } from './interface'; +import { registerEventSeries } from './series/event-series'; +import { BaseChart, Factory, registerMarkTooltipProcessor } from '@visactor/vchart'; + +export class TimelineChart extends BaseChart { + static readonly type: string = 'timeline'; + static readonly seriesType: string = 'event'; + static readonly transformerConstructor = TimelineChartSpecTransformer; + readonly transformerConstructor = TimelineChartSpecTransformer; + readonly type: string = 'timeline'; + readonly seriesType: string = 'event'; +} + +export const registerTimelineChart = () => { + registerMarkTooltipProcessor(); + registerEventSeries(); + Factory.registerChart(TimelineChart.type, TimelineChart); +}; diff --git a/packages/vchart-extension/src/components/map-label/map-label-transformer.ts b/packages/vchart-extension/src/components/map-label/map-label-transformer.ts index 1b7f78450d..32c5782adc 100644 --- a/packages/vchart-extension/src/components/map-label/map-label-transformer.ts +++ b/packages/vchart-extension/src/components/map-label/map-label-transformer.ts @@ -1,5 +1,5 @@ import { BaseComponentSpecTransformer } from '@visactor/vchart'; -import { IMapLabelSpec, IMapLabelStyleSpec } from './type'; +import type { IMapLabelSpec, IMapLabelStyleSpec } from './type'; import { mapLabel } from './theme'; export class MapLabelSpecTransformer< diff --git a/packages/vchart-extension/src/index.ts b/packages/vchart-extension/src/index.ts index 6db81fb8fe..ae1e1b5c8e 100644 --- a/packages/vchart-extension/src/index.ts +++ b/packages/vchart-extension/src/index.ts @@ -17,6 +17,7 @@ export { register3DPlugin } from './charts/3d/plugin'; export * from './charts/pictogram'; export * from './charts/image-cloud'; export * from './charts/candlestick'; +export * from './charts/timeline'; export * from './components/series-break'; export * from './components/bar-link'; diff --git a/packages/vchart/src/vchart-all.ts b/packages/vchart/src/vchart-all.ts index a621f7955a..2f516e085c 100644 --- a/packages/vchart/src/vchart-all.ts +++ b/packages/vchart/src/vchart-all.ts @@ -94,6 +94,7 @@ VChart.useRegisters([ registerSequenceChart, registerCorrelationChart, // 优化vchart-all体积, 默认不注册 + // registerTimelineChart, // registerLiquidChart, // registerVennChart, registerCommonChart, diff --git a/specs/001-add-timeline-chart/checklists/requirements.md b/specs/001-add-timeline-chart/checklists/requirements.md new file mode 100644 index 0000000000..69feec104a --- /dev/null +++ b/specs/001-add-timeline-chart/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Timeline 图表类型 + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-01-28 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan` diff --git a/specs/001-add-timeline-chart/contracts/timeline-spec.yaml b/specs/001-add-timeline-chart/contracts/timeline-spec.yaml new file mode 100644 index 0000000000..f48cc35039 --- /dev/null +++ b/specs/001-add-timeline-chart/contracts/timeline-spec.yaml @@ -0,0 +1,128 @@ +openapi: 3.0.3 +info: + title: Timeline Spec Contract + version: 1.0.0 + description: Public configuration contract for Timeline chart specifications. +paths: + /timeline/validate: + post: + summary: Validate a timeline chart spec + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TimelineSpec' + responses: + '200': + description: Validation result + content: + application/json: + schema: + type: object + properties: + valid: + type: boolean + errors: + type: array + items: + type: string +components: + schemas: + TimelineSpec: + type: object + required: + - type + - data + - layout + properties: + type: + type: string + enum: [timeline] + layout: + $ref: '#/components/schemas/TimelineLayout' + data: + $ref: '#/components/schemas/TimelineData' + TimelineLayout: + type: object + required: + - type + properties: + type: + type: string + enum: [horizontal, vertical, radial, s-curve] + options: + type: object + additionalProperties: true + TimelineData: + type: object + required: + - eventPoints + properties: + timePoints: + type: array + items: + $ref: '#/components/schemas/TimePoint' + eventPoints: + type: array + items: + $ref: '#/components/schemas/EventPoint' + series: + type: array + items: + $ref: '#/components/schemas/Series' + TimePoint: + type: object + required: + - id + - time + properties: + id: + type: string + time: + oneOf: + - type: string + - type: number + label: + type: string + meta: + type: object + additionalProperties: true + EventPoint: + type: object + required: + - id + - time + - title + properties: + id: + type: string + time: + oneOf: + - type: string + - type: number + title: + type: string + description: + type: string + seriesId: + type: string + seriesName: + type: string + icon: + type: string + meta: + type: object + additionalProperties: true + Series: + type: object + required: + - id + properties: + id: + type: string + name: + type: string + style: + type: object + additionalProperties: true diff --git a/specs/001-add-timeline-chart/data-model.md b/specs/001-add-timeline-chart/data-model.md new file mode 100644 index 0000000000..5348a78b48 --- /dev/null +++ b/specs/001-add-timeline-chart/data-model.md @@ -0,0 +1,44 @@ +# Data Model: Timeline 图表类型 + +## Entities + +### 时间点 (TimePoint) +- Fields: + - id: String + - time: Date/String/Number + - label: String (optional) + - meta: Object (optional) + +### 事件点 (EventPoint) +- Fields: + - id: String + - time: Date/String/Number + - title: String + - description: String (optional) + - seriesId: String (optional) + - seriesName: String (optional) + - icon: String (optional) + - meta: Object (optional) + +### 系列 (Series) +- Fields: + - id: String + - name: String + - style: Object (optional) + +## Spec 结构(概要) +- type: 'timeline' +- data: + - timePoints: TimePoint[] + - eventPoints: EventPoint[] + - series: Series[] (optional) +- layout: + - type: 'horizontal' | 'vertical' | 'radial' | 's-curve' + - options: Object +- tooltip/crosshair/legend: 复用现有组件结构 + +## Validation Rules +- 所有 EventPoint 必须包含可解析的 time +- layout.type 必须为支持的枚举之一 +- 多系列时,seriesId/seriesName 至少一个有效 +- timePoints 可为空,但必须支持仅 timePoints 的展示 diff --git a/specs/001-add-timeline-chart/plan.md b/specs/001-add-timeline-chart/plan.md new file mode 100644 index 0000000000..9a161f2927 --- /dev/null +++ b/specs/001-add-timeline-chart/plan.md @@ -0,0 +1,70 @@ +# Implementation Plan: Timeline 图表类型 + +**Branch**: `001-add-timeline-chart` | **Date**: 2026-01-28 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/001-add-timeline-chart/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow. + +## Summary + +新增 Timeline 图表类型,支持展示事件点与时间点,并具备横向、纵向、径向、S 线等布局类型,支持多系列事件点。技术上在现有 VChart 架构内实现新的 chart 类型与布局模块,遵循跨端一致性与类型约束,提供清晰的 Spec 与示例。 + +## Technical Context + +**Language/Version**: TypeScript (strict) +**Primary Dependencies**: @visactor/vchart, @visactor/vchart-types, VRender/VGrammar(内部体系) +**Storage**: N/A +**Testing**: Jest/Vitest 单元测试 + 视觉回归 +**Target Platform**: Web(桌面/移动)、多端封装一致行为 +**Project Type**: Monorepo(Rush) +**Performance Goals**: 60fps 交互,Tooltip/Crosshair 响应 <16ms +**Constraints**: 体积控制,跨端一致性,类型严格,无控制台调试输出 +**Scale/Scope**: 新增一个图表类型及其布局/渲染/交互,不引入外部服务 + +## Constitution Check + +_GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._ + +- 规范驱动:已有 spec.md,后续设计与实现遵循任务清单 +- 类型对齐:新增类型需落位 `@visactor/vchart-types` +- 代码质量:遵循 ESLint/Prettier,严格 TypeScript +- 测试:需新增单元测试与视觉回归用例 +- 文档与示例:需在 docs 站点提供示例与 API 文档 +- 跨端一致性:Tooltip/Crosshair/Legend 等交互行为需对齐 +- 许可证与依赖:不新增不兼容许可证依赖 + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-add-timeline-chart/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + +```text +packages/vchart/ +├── src/chart/timeline/ # Timeline 图表主实现 +├── src/component/... # 复用或新增组件 +├── src/theme/... # 默认主题扩展(如需) +└── __tests__/... # 单元与视觉测试用例 + +packages/vchart-types/ +└── types/chart/timeline.d.ts # Timeline 的对外类型定义 + +packages/docs/ (或内部 docs 包) +└── docs/timeline/* # 文档与示例 +``` + +**Structure Decision**: 在 vchart 核心包内新增 timeline chart 目录;类型定义落位 vchart-types;示例与文档在 docs 包维护;测试用例与视觉回归在 vchart 内。 + +## Complexity Tracking + +No constitution violations identified for this feature. diff --git a/specs/001-add-timeline-chart/quickstart.md b/specs/001-add-timeline-chart/quickstart.md new file mode 100644 index 0000000000..13225c9ae3 --- /dev/null +++ b/specs/001-add-timeline-chart/quickstart.md @@ -0,0 +1,40 @@ +# Quickstart: Timeline 图表类型 + +## 目标 +用最小配置创建一个 Timeline 图表,展示时间点与事件点,并切换布局。 + +## 步骤 + +1. 准备数据 + - 至少包含一组事件点 + - 可选提供时间点与系列信息 + +2. 选择布局类型 + - horizontal / vertical / radial / s-curve + +3. 渲染图表 + - 使用 Timeline 图表类型与配置项 + +## 最小示例(结构示意) + +```json +{ + "type": "timeline", + "layout": { "type": "horizontal" }, + "data": { + "timePoints": [ + { "id": "t1", "time": "2025-01-01", "label": "开始" } + ], + "eventPoints": [ + { "id": "e1", "time": "2025-01-01", "title": "事件 A" } + ], + "series": [ + { "id": "s1", "name": "系列 1" } + ] + } +} +``` + +## 成功标准 +- 图表能展示时间点与事件点 +- 切换布局后视觉结构发生变化 diff --git a/specs/001-add-timeline-chart/research.md b/specs/001-add-timeline-chart/research.md new file mode 100644 index 0000000000..cc2520b931 --- /dev/null +++ b/specs/001-add-timeline-chart/research.md @@ -0,0 +1,27 @@ +# Research: Timeline 图表类型 + +## 决策与依据 + +### Decision 1: 支持四种布局类型作为首期范围 +- **Decision**: 首期支持横向、纵向、径向、S 线布局 +- **Rationale**: 覆盖信息图中最常见的时间轴呈现方式,满足多场景展示 +- **Alternatives considered**: 仅支持横纵布局(功能不足),增加更多复杂布局(成本过高) + +### Decision 2: 事件点与时间点作为独立实体 +- **Decision**: 事件点与时间点独立建模,事件点关联时间点 +- **Rationale**: 支持仅时间点或密集事件点的场景,同时增强多系列扩展性 +- **Alternatives considered**: 仅事件点(无法表达空时间点),仅时间点(无法表达事件内容) + +### Decision 3: 多系列以系列标识区分 +- **Decision**: 使用 seriesId/seriesName 区分多系列事件点 +- **Rationale**: 与现有图表体系一致,便于样式与交互对齐 +- **Alternatives considered**: 仅颜色区分(缺乏语义)、嵌套结构(复杂度更高) + +### Decision 4: 交互一致性与性能目标沿用项目规范 +- **Decision**: Tooltip/Crosshair/Legend 交互对齐全局规范,交互响应 <16ms +- **Rationale**: 符合宪法要求,减少跨端差异 +- **Alternatives considered**: 允许不同端行为(增加维护成本) + +## 未解决问题 + +无 NEEDS CLARIFICATION 项,研究阶段完成。 diff --git a/specs/001-add-timeline-chart/spec.md b/specs/001-add-timeline-chart/spec.md new file mode 100644 index 0000000000..6394fdbf00 --- /dev/null +++ b/specs/001-add-timeline-chart/spec.md @@ -0,0 +1,97 @@ +# Feature Specification: Timeline 图表类型 + +**Feature Branch**: `001-add-timeline-chart` +**Created**: 2026-01-28 +**Status**: Draft +**Input**: User description: "我想要实现一个新的图表类型,Timeline ,用于封装信息图领域的图表时间轴,需要支持的功能有:1. 时间轴图表需要展示:事件点、时间点 2. 时间点支持多种布局类型:横向布局、纵向布局、径向布局、S线布局等 3. 可能有多个系列来展示事件点" + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - 创建并展示时间轴图表 (Priority: P1) + +可视化开发者需要用 Timeline 图表展示一系列事件点与时间点,以便在信息图中清晰表达时间顺序与事件分布。 + +**Why this priority**: 这是该图表类型的核心用途,没有它无法构成可用的最小功能集。 + +**Independent Test**: 使用一组包含时间点与事件点的数据创建 Timeline 图表,确认可视化结果包含事件点与时间点。 + +**Acceptance Scenarios**: + +1. **Given** 一组包含时间点与事件点的数据,**When** 渲染 Timeline 图表,**Then** 图表同时展示事件点与时间点。 +2. **Given** 事件点包含名称与时间信息,**When** 渲染 Timeline 图表,**Then** 用户可以从图表中识别事件发生顺序。 + +--- + +### User Story 2 - 切换时间轴布局 (Priority: P2) + +可视化开发者需要在不同场景下选择不同布局,以适配横向、纵向、径向或 S 线等信息图设计风格。 + +**Why this priority**: 布局适配是时间轴图表在信息图场景中的关键差异化能力。 + +**Independent Test**: 对同一数据分别选择横向、纵向、径向、S 线布局并渲染,确认布局方向与结构发生变化。 + +**Acceptance Scenarios**: + +1. **Given** 一组时间轴数据,**When** 选择横向布局,**Then** 时间轴按水平方向展开。 +2. **Given** 一组时间轴数据,**When** 选择纵向布局,**Then** 时间轴按垂直方向展开。 +3. **Given** 一组时间轴数据,**When** 选择径向布局,**Then** 时间轴围绕中心呈放射结构。 +4. **Given** 一组时间轴数据,**When** 选择 S 线布局,**Then** 时间轴呈连续曲线结构排列。 + +--- + +### User Story 3 - 展示多系列事件点 (Priority: P3) + +可视化开发者需要同时展示多个系列的事件点,以表达不同来源或类型的事件分布。 + +**Why this priority**: 多系列能力提升表达力,但在没有该能力时仍能满足基本使用。 + +**Independent Test**: 提供包含多个系列标识的事件点数据并渲染,确认不同系列可同时显示。 + +**Acceptance Scenarios**: + +1. **Given** 含有多个系列标识的事件点数据,**When** 渲染 Timeline 图表,**Then** 不同系列的事件点同时出现。 + +--- + +### Edge Cases + +- 当数据中仅包含时间点且没有事件点时,图表仍能展示时间点。 +- 当事件点时间重复或密集时,图表仍能展示所有事件点而不丢失。 +- 当选择的布局类型不支持当前数据时,图表能够给出可理解的提示或降级表现。 + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: 系统必须支持 Timeline 图表类型的创建与渲染。 +- **FR-002**: 系统必须支持时间轴图表同时展示时间点与事件点。 +- **FR-003**: 系统必须支持横向布局的时间轴展示。 +- **FR-004**: 系统必须支持纵向布局的时间轴展示。 +- **FR-005**: 系统必须支持径向布局的时间轴展示。 +- **FR-006**: 系统必须支持 S 线布局的时间轴展示。 +- **FR-007**: 系统必须支持同一时间轴内多个系列的事件点展示。 +- **FR-008**: 系统必须允许用户在多个布局类型之间切换。 + +### Key Entities _(include if feature involves data)_ + +- **时间点**: 表示时间轴上的关键时间节点,包含时间值与可选的描述信息。 +- **事件点**: 表示发生在时间轴上的事件,包含时间信息、事件内容与所属系列标识。 +- **系列**: 用于区分不同来源或类型的事件点集合。 + +## Assumptions + +- 默认提供可读的时间顺序展示逻辑,无需用户手动排序数据。 +- 默认使用一致的视觉区分来标识不同系列的事件点。 +- 当布局类型切换时,图表内容保持不变,仅布局结构发生变化。 + +## Dependencies + +- 无外部系统或第三方服务依赖。 + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: 90% 的试用用户能够在 5 分钟内创建并展示包含时间点与事件点的时间轴图表。 +- **SC-002**: 用户能够在 2 分钟内完成至少两种布局类型的切换并验证呈现差异。 +- **SC-003**: 95% 的多系列数据在渲染后仍能完整展示所有事件点。 diff --git a/specs/001-add-timeline-chart/tasks.md b/specs/001-add-timeline-chart/tasks.md new file mode 100644 index 0000000000..18058513ca --- /dev/null +++ b/specs/001-add-timeline-chart/tasks.md @@ -0,0 +1,67 @@ +# Tasks: Timeline 图表类型 + +**Input**: Design documents from `/specs/001-add-timeline-chart/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/ + +## Phase 1: Setup (Shared Infrastructure) + +- [ ] T001 Verify spec artifacts exist under specs/001-add-timeline-chart/ +- [ ] T002 [P] Run targeted build for @visactor/vchart +- [ ] T003 [P] Add docs navigation placeholder for Timeline in packages/docs + +## Phase 2: Foundational (Blocking Prerequisites) + +- [ ] T004 Define Timeline public types in packages/vchart-types/types/chart/timeline.d.ts +- [ ] T005 [P] Register timeline chart in packages/vchart/src/vchart-all.ts +- [ ] T006 [P] Create chart entry dir packages/vchart/src/chart/timeline/ +- [ ] T007 Establish unit test scaffold in packages/vchart/**tests**/timeline/ +- [ ] T008 [P] Add schema generation hookup in packages/vchart-schema for Timeline spec + +## Phase 3: User Story 1 - 创建并展示时间轴图表 (Priority: P1) 🎯 MVP + +**Goal**: 渲染包含时间点与事件点的 Timeline 基本图表 +**Independent Test**: 提供最小数据,成功渲染并可视识别时间顺序与事件 + +- [ ] T009 [P] [US1] Implement timeline spec transformer in packages/vchart/src/chart/timeline/transformer.ts +- [ ] T010 [P] [US1] Implement layout engine (horizontal/vertical) in packages/vchart/src/chart/timeline/layout/base.ts +- [ ] T011 [US1] Render time axis and event markers in packages/vchart/src/chart/timeline/render.ts +- [ ] T012 [US1] Wire data ingestion for timePoints/eventPoints in packages/vchart/src/chart/timeline/data.ts +- [ ] T013 [US1] Add minimal theme defaults for timeline in packages/vchart/src/theme/chart/timeline.ts +- [ ] T014 [US1] Add unit tests for transformer and data handling in packages/vchart/**tests**/timeline/ +- [ ] T015 [US1] Add visual regression case for basic timeline in packages/vchart/**tests**/runtime/browser/ +- [ ] T016 [US1] Create runtime demo in **tests**/runtime/browser/test-page/timeline-basic.ts + +## Phase 4: User Story 2 - 切换时间轴布局 (Priority: P2) + +**Goal**: 支持横向、纵向、径向、S 线布局切换 +**Independent Test**: 对同一数据切换布局后结构明显变化 + +- [ ] T017 [P] [US2] Implement vertical layout strategy in packages/vchart/src/chart/timeline/layout/vertical.ts +- [ ] T018 [P] [US2] Implement radial layout strategy in packages/vchart/src/chart/timeline/layout/radial.ts +- [ ] T019 [P] [US2] Implement s-curve layout strategy in packages/vchart/src/chart/timeline/layout/s-curve.ts +- [ ] T020 [US2] Add layout type switching in transformer and spec in packages/vchart/src/chart/timeline/transformer.ts +- [ ] T021 [US2] Add layout switch tests in packages/vchart/**tests**/timeline/ +- [ ] T022 [US2] Add layout demo in **tests**/runtime/browser/test-page/timeline-layouts.ts + +## Phase 5: User Story 3 - 展示多系列事件点 (Priority: P3) + +**Goal**: 多系列事件点同时展示并可区分 +**Independent Test**: 同一时间轴显示多个系列,颜色/图例区分明显 + +- [ ] T023 [P] [US3] Series mapping for eventPoints in packages/vchart/src/chart/timeline/data.ts +- [ ] T024 [US3] Legend integration and series styling in packages/vchart/src/chart/timeline/render.ts +- [ ] T025 [US3] Add multi-series unit tests in packages/vchart/**tests**/timeline/ +- [ ] T026 [US3] Multi-series demo in **tests**/runtime/browser/test-page/timeline-series.ts + +## Phase N: Polish & Cross-Cutting Concerns + +- [ ] T027 [P] Add docs examples and API reference in packages/docs/docs/timeline/ +- [ ] T028 Add dense events performance test and tuning in packages/vchart/src/chart/timeline/layout/\* +- [ ] T029 Add edge-case handling for timePoints-only datasets in packages/vchart/src/chart/timeline/data.ts +- [ ] T030 Add fallback behavior for unsupported layout types in packages/vchart/src/chart/timeline/transformer.ts +- [ ] T031 Add quickstart example validation using specs/001-add-timeline-chart/quickstart.md + +## Dependencies & Execution Order + +- Setup → Foundational → US1 → US2 → US3 → Polish +- [P] 任务表示可并行(不同文件、无未完成依赖) From d50b1bea0073915b0d428e3135f3d7ee9b5c93bf Mon Sep 17 00:00:00 2001 From: xile611 Date: Fri, 30 Jan 2026 18:21:40 +0800 Subject: [PATCH 2/7] fix: fix timeline in vchart-extensions --- .../test-page/timeline/demo-horizontal.ts | 150 ++++++++++ .../test-page/timeline/demo-vertical.ts | 137 +++++++++ .../browser/test-page/timeline/group.ts | 2 +- .../browser/test-page/timeline/horizontal.ts | 2 +- .../browser/test-page/timeline/vertical.ts | 2 +- .../src/charts/timeline/index.ts | 4 +- .../src/charts/timeline/interface.ts | 3 - .../charts/timeline/series/event-series.ts | 280 +++++++----------- .../src/charts/timeline/series/interface.ts | 29 +- .../src/charts/timeline/series/theme.ts | 30 ++ .../charts/timeline/timeline-transformer.ts | 120 ++++++-- 11 files changed, 546 insertions(+), 213 deletions(-) create mode 100644 packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/demo-horizontal.ts create mode 100644 packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/demo-vertical.ts create mode 100644 packages/vchart-extension/src/charts/timeline/series/theme.ts diff --git a/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/demo-horizontal.ts b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/demo-horizontal.ts new file mode 100644 index 0000000000..0d8b8ac1bc --- /dev/null +++ b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/demo-horizontal.ts @@ -0,0 +1,150 @@ +import { VChart, type Datum } from '@visactor/vchart'; +import { registerTimelineChart } from '../../../../../src'; +import type { ITimelineChartSpec } from '../../../../../src/charts/timeline'; + +/** + * 企业优势列表 - 水平布局示例 + * 参考设计图复现 + */ + +const timelineData = [ + { + id: '1', + year: '2021', + title: '品牌影响力', + detail: '在目标用户群中具备较强认知与信任度', + time: 1, + color: '#4A90E2' + }, + { + id: '2', + year: '2022', + title: '技术研发力', + detail: '拥有自研核心系统与持续创新能力', + time: 2, + color: '#50C8C8' + }, + { + id: '3', + year: '2023', + title: '市场增长快', + detail: '近一年用户规模实现快速增长', + time: 3, + color: '#F5A623' + }, + { + id: '4', + year: '2020', + title: '服务满意度', + detail: '用户对服务体系整体评分较高', + time: 4, + color: '#9B59B6' + }, + { + id: '5', + year: '2022', + title: '数据资产全', + detail: '构建了完整用户标签与画像体系', + time: 5, + color: '#8E44AD' + }, + { + id: '6', + year: '2023', + title: '创新能力强', + detail: '新产品上线频率高于行业平均', + time: 6, + color: '#2ECC71' + } +]; + +const spec: ITimelineChartSpec = { + type: 'timeline', + name: 'enterprise-advantages', + direction: 'horizontal', + padding: { + left: 60, + right: 60, + top: 150, + bottom: 150 + }, + background: '#f5f5f5', + data: [ + { + id: 'timeline-data', + values: timelineData + } + ], + title: { + visible: true, + text: '企业优势列表', + subtext: '展示企业在不同维度上的核心优势与表现值', + style: { + fontSize: 24, + fontWeight: 'bold', + fill: '#333' + }, + subtextStyle: { + fontSize: 14, + fill: '#666' + } + }, + series: [ + { + type: 'event', + dataId: 'timeline-data', + timeField: 'time', + eventField: 'title', + subTitleField: 'detail', + labelPosition: 'top-bottom', + dot: { + style: { + size: 12, + fill: (datum: Datum) => String((datum as Record).color ?? '#4A90E2'), + stroke: '#fff', + lineWidth: 2 + } + }, + title: { + style: { + fill: '#333', + fontSize: 14, + fontWeight: 'bold' + } + }, + subTitle: { + style: { + fill: '#666', + fontSize: 12, + lineHeight: 18 + } + }, + line: { + style: { + stroke: '#c0c3c7', + lineWidth: 2 + } + } + } + ] +}; + +declare global { + interface Window { + vchart?: VChart; + } +} + +const run = () => { + registerTimelineChart(); + const cs = new VChart(spec, { + dom: document.getElementById('chart') as HTMLElement, + onError: err => { + console.error(err); + } + }); + cs.renderSync(); + window.vchart = cs; +}; + +run(); diff --git a/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/demo-vertical.ts b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/demo-vertical.ts new file mode 100644 index 0000000000..f7670447ae --- /dev/null +++ b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/demo-vertical.ts @@ -0,0 +1,137 @@ +import { VChart, type Datum } from '@visactor/vchart'; +import { registerTimelineChart } from '../../../../../src'; +import type { ITimelineChartSpec } from '../../../../../src/charts/timeline'; + +/** + * 企业发展时间线 - 垂直布局示例 + * 参考设计图复现 + */ + +const timelineData = [ + { + id: '01', + year: '2018年', + title: '企业成立,完成初期团队搭建和产品定位', + time: 1, + color: '#4A90E2' + }, + { + id: '02', + year: '2020年', + title: '发布首款核心产品,打开区域市场', + time: 2, + color: '#50C8C8' + }, + { + id: '03', + year: '2021年', + title: '启动数字化平台,提升内部运营效率', + time: 3, + color: '#F5A623' + }, + { + id: '04', + year: '2022年', + title: '完成A轮融资,加速市场拓展布局', + time: 4, + color: '#9B59B6' + }, + { + id: '05', + year: '2024年', + title: '推进生态合作,拓展全国影响力', + time: 5, + color: '#5B6AE0' + } +]; + +const spec: ITimelineChartSpec = { + type: 'timeline', + name: 'enterprise-development', + direction: 'vertical', + padding: { + left: 200, + right: 200, + top: 80, + bottom: 80 + }, + background: '#f5f5f5', + data: [ + { + id: 'timeline-data', + values: timelineData + } + ], + title: { + visible: true, + text: '企业发展时间线', + subtext: '展示企业在关键年份的战略动作与发展节点', + style: { + fontSize: 24, + fontWeight: 'bold', + fill: '#333' + }, + subtextStyle: { + fontSize: 14, + fill: '#666' + } + }, + series: [ + { + type: 'event', + dataId: 'timeline-data', + timeField: 'time', + eventField: 'year', + subTitleField: 'title', + labelPosition: 'left-right', + dot: { + style: { + size: 14, + fill: (datum: Datum) => String((datum as Record).color ?? '#4A90E2'), + stroke: '#fff', + lineWidth: 2 + } + }, + title: { + style: { + fill: '#333', + fontSize: 16, + fontWeight: 'bold' + } + }, + subTitle: { + style: { + fill: '#666', + fontSize: 13, + lineHeight: 20 + } + }, + line: { + style: { + stroke: 'red', + lineWidth: 3 + } + } + } + ] +}; + +declare global { + interface Window { + vchart?: VChart; + } +} + +const run = () => { + registerTimelineChart(); + const cs = new VChart(spec, { + dom: document.getElementById('chart') as HTMLElement, + onError: err => { + console.error(err); + } + }); + cs.renderSync(); + window.vchart = cs; +}; + +run(); diff --git a/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/group.ts b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/group.ts index 3effa0dfeb..22ad1c0c2c 100644 --- a/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/group.ts +++ b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/group.ts @@ -64,7 +64,7 @@ const getDatumString = (datum: Datum | undefined, key: string) => { const spec: ITimelineChartSpec = { type: 'timeline', name: 'timeline-group', - layoutType: 'horizontal', + direction: 'horizontal', padding: { left: 80, right: 40, diff --git a/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/horizontal.ts b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/horizontal.ts index 73f6f6dfd2..c077013e76 100644 --- a/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/horizontal.ts +++ b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/horizontal.ts @@ -52,7 +52,7 @@ const getDatumString = (datum: Datum | undefined, key: string) => { const spec: ITimelineChartSpec = { type: 'timeline', name: 'timeline-horizontal', - layoutType: 'horizontal', + direction: 'horizontal', padding: { left: 80, right: 80, diff --git a/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/vertical.ts b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/vertical.ts index 899e0cb893..0972a4d9a0 100644 --- a/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/vertical.ts +++ b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/vertical.ts @@ -70,7 +70,7 @@ const getDatumString = (datum: Datum | undefined, key: string) => { const spec: ITimelineChartSpec = { type: 'timeline', name: 'timeline-vertical', - layoutType: 'vertical', + direction: 'vertical', data: [ { id: 'timeline-data', diff --git a/packages/vchart-extension/src/charts/timeline/index.ts b/packages/vchart-extension/src/charts/timeline/index.ts index a4bd6f828d..444d0d4b42 100644 --- a/packages/vchart-extension/src/charts/timeline/index.ts +++ b/packages/vchart-extension/src/charts/timeline/index.ts @@ -1,4 +1,4 @@ export * from './interface'; export * from './timeline'; -export * from './series/interface'; -export * from './series/event-series'; +export type { IEventSeriesSpec, IEventSeriesTheme } from './series/interface'; +export { registerEventSeries } from './series/event-series'; diff --git a/packages/vchart-extension/src/charts/timeline/interface.ts b/packages/vchart-extension/src/charts/timeline/interface.ts index 544e149993..2a2ac33fd3 100644 --- a/packages/vchart-extension/src/charts/timeline/interface.ts +++ b/packages/vchart-extension/src/charts/timeline/interface.ts @@ -1,13 +1,10 @@ import type { IChartExtendsSeriesSpec, IChartSpec, ICartesianAxisSpec } from '@visactor/vchart'; import type { IEventSeriesSpec } from './series/interface'; -export type TimelineLayoutType = 'horizontal' | 'vertical' | 'radial' | 's-curve'; - export interface ITimelineChartSpec extends IChartSpec, Omit, 'type' | 'title'> { type: 'timeline'; - layoutType?: TimelineLayoutType; series?: IEventSeriesSpec[]; axes?: ICartesianAxisSpec[]; } diff --git a/packages/vchart-extension/src/charts/timeline/series/event-series.ts b/packages/vchart-extension/src/charts/timeline/series/event-series.ts index 31126d68ef..b7aaac6de7 100644 --- a/packages/vchart-extension/src/charts/timeline/series/event-series.ts +++ b/packages/vchart-extension/src/charts/timeline/series/event-series.ts @@ -1,8 +1,7 @@ import type { StringOrNumber } from '@visactor/vchart'; import { AttributeLevel, - BaseSeries, - DEFAULT_DATA_KEY, + CartesianSeries, Factory, MarkTypeEnum, SeriesMarkNameEnum, @@ -19,21 +18,15 @@ import { } from '@visactor/vchart'; import { EVENT_SERIES_TYPE } from './constant'; import type { IEventSeriesSpec, LabelPosition } from './interface'; +import { event } from './theme'; type AxisHelper = { isContinuous?: boolean; getSpec?: () => { type?: string }; - dataToPosition?: (values: any[], cfg?: { bandPosition?: number }) => number; - valueToPosition?: (value: any) => number; + dataToPosition?: (values: unknown[], cfg?: { bandPosition?: number }) => number; + valueToPosition?: (value: unknown) => number; }; -/** 默认 title 字体大小 */ -const DEFAULT_TITLE_FONT_SIZE = 14; -/** 默认 dot 和 label 之间的间距 */ -const DEFAULT_DOT_LABEL_GAP = 6; -/** 默认 title 和 subTitle 之间的间距 */ -const DEFAULT_TITLE_SUBTITLE_GAP = 4; - const eventSeriesMark: SeriesMarkMap = { ...baseSeriesMark, [SeriesMarkNameEnum.line]: { name: SeriesMarkNameEnum.line, type: MarkTypeEnum.line }, @@ -42,14 +35,17 @@ const eventSeriesMark: SeriesMarkMap = { [SeriesMarkNameEnum.subTitle]: { name: SeriesMarkNameEnum.subTitle, type: MarkTypeEnum.text } }; -export class EventSeries extends BaseSeries { +export class EventSeries extends CartesianSeries { static readonly type: string = EVENT_SERIES_TYPE; type = EVENT_SERIES_TYPE as any; static readonly mark: SeriesMarkMap = eventSeriesMark; + static readonly builtInTheme = { event }; static readonly transformerConstructor = BaseSeriesSpecTransformer as any; readonly transformerConstructor = BaseSeriesSpecTransformer as any; + readonly coordinate: 'cartesian' = 'cartesian'; + protected declare _spec: T; private _dotMark?: IMark; @@ -60,12 +56,9 @@ export class EventSeries extends private _timeField?: string; private _eventField?: string; private _subTitleField?: string; - private _layoutType?: T['layoutType']; private _labelPosition?: LabelPosition; - private _xAxisHelper?: AxisHelper; - private _yAxisHelper?: AxisHelper; - private _scaleX?: unknown; - private _scaleY?: unknown; + private _dotLabelGap?: number; + private _titleSubTitleGap?: number; setAttrFromSpec(): void { super.setAttrFromSpec(); @@ -73,8 +66,9 @@ export class EventSeries extends this._timeField = this._spec.timeField as string | undefined; this._eventField = this._spec.eventField; this._subTitleField = this._spec.subTitleField; - this._layoutType = this._spec.layoutType; this._labelPosition = this._spec.labelPosition; + this._dotLabelGap = this._spec.dotLabelGap ?? 6; + this._titleSubTitleGap = this._spec.titleSubTitleGap ?? 4; } getDimensionField(): string[] { @@ -127,10 +121,10 @@ export class EventSeries extends const subTitleStyle = this._spec.subTitle?.style ?? {}; // 获取 title 字体大小,用于计算 subTitle 的位置 - const titleFontSize = typeof titleStyle.fontSize === 'number' ? titleStyle.fontSize : DEFAULT_TITLE_FONT_SIZE; + const titleFontSize = typeof titleStyle.fontSize === 'number' ? titleStyle.fontSize : 14; // dot 和 label 之间的间距 - const labelOffset = dotSize / 2 + DEFAULT_DOT_LABEL_GAP; + const labelOffset = dotSize / 2 + (this._dotLabelGap ?? 6); if (this._dotMark) { this.setMarkStyle( @@ -150,12 +144,12 @@ export class EventSeries extends this.setMarkStyle( this._titleMark, { - fontSize: DEFAULT_TITLE_FONT_SIZE, + fontSize: 14, ...titleStyle, x: (datum: Datum) => this._getTitlePosition(datum, labelOffset).x, y: (datum: Datum) => this._getTitlePosition(datum, labelOffset).y, textAlign: (datum: Datum) => this._getLabelTextAlign(datum), - textBaseline: (datum: Datum) => this._getLabelTextBaseline(datum), + textBaseline: (datum: Datum) => this._getLabelTextBaseline(datum, true), text: (datum: Datum) => this._getDatumString(datum, this._eventField) }, STATE_VALUE_ENUM.STATE_NORMAL, @@ -164,17 +158,14 @@ export class EventSeries extends } if (this._subTitleMark) { - // subTitle 位置 = title 位置 + title 字体大小 + 间距 - const subTitleOffset = labelOffset + titleFontSize + DEFAULT_TITLE_SUBTITLE_GAP; - this.setMarkStyle( this._subTitleMark, { ...subTitleStyle, - x: (datum: Datum) => this._getSubTitlePosition(datum, subTitleOffset).x, - y: (datum: Datum) => this._getSubTitlePosition(datum, subTitleOffset).y, + x: (datum: Datum) => this._getSubTitlePosition(datum, labelOffset, titleFontSize).x, + y: (datum: Datum) => this._getSubTitlePosition(datum, labelOffset, titleFontSize).y, textAlign: (datum: Datum) => this._getLabelTextAlign(datum), - textBaseline: (datum: Datum) => this._getLabelTextBaseline(datum), + textBaseline: (datum: Datum) => this._getLabelTextBaseline(datum, false), text: (datum: Datum) => this._getDatumString(datum, this._subTitleField) }, STATE_VALUE_ENUM.STATE_NORMAL, @@ -192,7 +183,7 @@ export class EventSeries extends const index = data.indexOf(datum); const position = this._labelPosition; - if (this._layoutType === 'vertical') { + if (this.direction === 'vertical') { // vertical 布局: left/right switch (position) { case 'left': @@ -227,7 +218,7 @@ export class EventSeries extends * 获取标签的文本对齐方式 */ private _getLabelTextAlign(datum: Datum): 'left' | 'right' | 'center' { - if (this._layoutType === 'vertical') { + if (this.direction === 'vertical') { const side = this._getLabelSide(datum); return side === 'primary' ? 'right' : 'left'; } @@ -237,9 +228,10 @@ export class EventSeries extends /** * 获取标签的文本基线 */ - private _getLabelTextBaseline(datum: Datum): 'top' | 'bottom' | 'middle' { - if (this._layoutType === 'vertical') { - return 'middle'; + private _getLabelTextBaseline(datum: Datum, isTitle: boolean): 'top' | 'bottom' | 'middle' { + if (this.direction === 'vertical') { + // vertical 布局时:title 用 top,subTitle 用 top + return 'top'; } const side = this._getLabelSide(datum); return side === 'primary' ? 'bottom' : 'top'; @@ -252,8 +244,8 @@ export class EventSeries extends const point = this._getPoint(datum); const side = this._getLabelSide(datum); - if (this._layoutType === 'vertical') { - // vertical: left/right + if (this.direction === 'vertical') { + // vertical: left/right,标签垂直排列 return { x: side === 'primary' ? point.x - offset : point.x + offset, y: point.y @@ -269,21 +261,28 @@ export class EventSeries extends /** * 获取 subTitle 的位置 */ - private _getSubTitlePosition(datum: Datum, offset: number): IPoint { + private _getSubTitlePosition(datum: Datum, offset: number, titleFontSize: number): IPoint { const point = this._getPoint(datum); const side = this._getLabelSide(datum); - if (this._layoutType === 'vertical') { - // vertical: left/right + if (this.direction === 'vertical') { + // vertical: left/right,subTitle 在 title 下方 + // offset 是 title 的偏移,subTitle 需要在 title 基础上再向下偏移 + const titleStyle = this._spec.title?.style ?? {}; + const titleLineHeight = typeof titleStyle.lineHeight === 'number' ? titleStyle.lineHeight : titleFontSize * 1.2; + return { x: side === 'primary' ? point.x - offset : point.x + offset, - y: point.y + y: point.y + titleLineHeight + this._titleSubTitleGap }; } // horizontal: top/bottom return { x: point.x, - y: side === 'primary' ? point.y - offset : point.y + offset + y: + side === 'primary' + ? point.y - (offset + titleFontSize + this._titleSubTitleGap) + : point.y + (offset + titleFontSize + this._titleSubTitleGap) }; } @@ -292,81 +291,92 @@ export class EventSeries extends } private _getPoint(datum: Datum): IPoint { - const rect = this._region.getLayoutRect(); - const data = this._getViewDataList(); - const index = data.indexOf(datum); - const count = Math.max(1, data.length); - - // 计算时间方向的位置 (水平布局时是 x,垂直布局时是 y) - let timePercent: number; - if (this._timeField && datum) { - const timeValue = (datum as Record)[this._timeField]; - if (typeof timeValue === 'number') { - // 获取所有时间值来计算范围 - const timeValues = data - .map(d => (d as Record)[this._timeField!] as number) - .filter(v => typeof v === 'number'); - const minTime = Math.min(...timeValues); - const maxTime = Math.max(...timeValues); - - if (maxTime !== minTime) { - timePercent = (timeValue - minTime) / (maxTime - minTime); - } else { - timePercent = 0.5; - } - } else { - timePercent = (index + 0.5) / count; - } - } else { - timePercent = (index + 0.5) / count; + if (this.direction === 'vertical') { + // vertical 布局:x 轴是分类方向(seriesField),y 轴是时间方向(timeField) + const x = this._getPositionFromAxis(datum, this.getXAxisHelper(), this._seriesField); + const y = this._getPositionFromAxis(datum, this.getYAxisHelper(), this._timeField); + return { x, y }; } - // 计算分类方向的位置(如果有 seriesField) - let categoryPercent = 0.5; - if (this._seriesField && datum) { - const categoryValue = (datum as Record)[this._seriesField]; - const uniqueCategories = Array.from(new Set(data.map(d => (d as Record)[this._seriesField!]))); - const categoryIndex = uniqueCategories.indexOf(categoryValue); - const categoryCount = Math.max(1, uniqueCategories.length); - categoryPercent = (categoryIndex + 0.5) / categoryCount; + // horizontal 布局:x 轴是时间方向(timeField),y 轴是分类方向(seriesField) + const x = this._getPositionFromAxis(datum, this.getXAxisHelper(), this._timeField); + const y = this._getPositionFromAxis(datum, this.getYAxisHelper(), this._seriesField); + return { x, y }; + } + + /** + * 根据轴的类型计算位置 + * @param datum 数据项 + * @param axisHelper 轴助手 + * @param field 字段名 + * @returns 位置值 + */ + private _getPositionFromAxis(datum: Datum, axisHelper: AxisHelper | undefined, field?: string): number { + if (!axisHelper) { + // 如果没有轴助手,使用默认的中间位置 + return this._getDefaultPosition(field); } - if (this._layoutType === 'vertical') { - return { - x: rect.width * categoryPercent, - y: rect.height * timePercent - }; + if (!field || !(field in datum)) { + // 如果没有字段或数据中没有该字段,使用默认位置 + return this._getDefaultPosition(field); } - return { - x: rect.width * timePercent, - y: rect.height * categoryPercent - }; + const value = (datum as Record)[field]; + const axisType = axisHelper.getSpec?.()?.type; + + // 根据轴类型选择不同的计算方式 + if (axisType === 'band' || !axisHelper.isContinuous) { + // band 轴:使用 dataToPosition,传入值数组 + return axisHelper.dataToPosition?.([value], { bandPosition: 0.5 }) ?? this._getDefaultPosition(field); + } + + // linear/time 轴:使用 valueToPosition,直接传入值 + return axisHelper.valueToPosition?.(value) ?? this._getDefaultPosition(field); } - private _getAxisPoints(datum: Datum): IPoint[] { + onXAxisHelperUpdate(): void { + super.onXAxisHelperUpdate?.(); + this.onMarkPositionUpdate(); + } + + onYAxisHelperUpdate(): void { + super.onYAxisHelperUpdate?.(); + this.onMarkPositionUpdate(); + } + + /** + * 获取默认位置(当没有轴或字段时使用) + */ + private _getDefaultPosition(field?: string): number { const rect = this._region.getLayoutRect(); - const data = this._getViewDataList(); - // 计算分类方向的位置 - let categoryPercent = 0.5; - if (this._seriesField && datum) { - const categoryValue = (datum as Record)[this._seriesField]; - const uniqueCategories = Array.from(new Set(data.map(d => (d as Record)[this._seriesField!]))); - const categoryIndex = uniqueCategories.indexOf(categoryValue); - const categoryCount = Math.max(1, uniqueCategories.length); - categoryPercent = (categoryIndex + 0.5) / categoryCount; + // 如果没有 seriesField,说明没有分类轴,返回中心位置 + if (!this._seriesField || field === this._seriesField) { + const isHorizontal = this.direction !== 'vertical'; + // horizontal 布局:返回垂直中心(y方向) + // vertical 布局:返回水平中心(x方向) + return isHorizontal ? rect.height * 0.5 : rect.width * 0.5; } - if (this._layoutType === 'vertical') { - const x = rect.width * categoryPercent; + // 对于时间轴,返回区域起点 + return 0; + } + + private _getAxisPoints(datum: Datum): IPoint[] { + const rect = this._region.getLayoutRect(); + + if (this.direction === 'vertical') { + // vertical 布局:轴是垂直的,x 位置根据 seriesField 计算 + const x = this._getPositionFromAxis(datum, this.getXAxisHelper(), this._seriesField); return [ { x, y: 0 }, { x, y: rect.height } ]; } - const y = rect.height * categoryPercent; + // horizontal 布局:轴是水平的,y 位置根据 seriesField 计算 + const y = this._getPositionFromAxis(datum, this.getYAxisHelper(), this._seriesField); return [ { x: 0, y }, { x: rect.width, y } @@ -383,7 +393,9 @@ export class EventSeries extends const categoryValue = (datum as Record)[this._seriesField]; // 找到该分类中的第一条数据 - const firstInGroup = data.find(d => (d as Record)[this._seriesField!] === categoryValue); + const firstInGroup = data.find( + d => this._seriesField && (d as Record)[this._seriesField] === categoryValue + ); return datum === firstInGroup; } @@ -395,70 +407,6 @@ export class EventSeries extends return typeof value === 'string' ? value : value == null ? '' : String(value); } - getStatisticFields(): { key: string; operations: Array<'max' | 'min' | 'values'>; customize?: any[] }[] { - const fields: { key: string; operations: Array<'max' | 'min' | 'values'>; customize?: any[] }[] = []; - - if (this._timeField) { - fields.push({ key: this._timeField, operations: ['max', 'min', 'values'] }); - } - - if (this._seriesField) { - fields.push({ key: this._seriesField, operations: ['values'] }); - } - - return fields; - } - - getGroupFields(): string[] { - return this._seriesField ? [this._seriesField] : []; - } - - getXAxisHelper(): AxisHelper | undefined { - return this._xAxisHelper; - } - - setXAxisHelper(h: AxisHelper) { - this._xAxisHelper = h; - this.onMarkPositionUpdate(); - } - - get scaleX(): unknown { - return this._scaleX; - } - - setScaleX(s: unknown) { - this._scaleX = s; - } - - getYAxisHelper(): AxisHelper | undefined { - return this._yAxisHelper; - } - - setYAxisHelper(h: AxisHelper) { - this._yAxisHelper = h; - this.onMarkPositionUpdate(); - } - - get scaleY(): unknown { - return this._scaleY; - } - - setScaleY(s: unknown) { - this._scaleY = s; - } - - dataToPosition(data: Datum): IPoint { - return this._getPoint(data); - } - - dataToPositionX(data: Datum): number { - return this._getPoint(data).x; - } - - dataToPositionY(data: Datum): number { - return this._getPoint(data).y; - } - valueToPosition(timeValue: StringOrNumber, eventValue?: StringOrNumber): IPoint { if (timeValue && typeof timeValue === 'object') { return this.dataToPosition(timeValue as Datum); @@ -474,14 +422,6 @@ export class EventSeries extends return this._getPoint(mockDatum as Datum); } - getStackGroupFields(): string[] { - return []; - } - - getStackValueField(): string | undefined { - return undefined; - } - getActiveMarks(): IMark[] { return [this._axisLineMark, this._dotMark, this._titleMark, this._subTitleMark].filter(Boolean) as IMark[]; } diff --git a/packages/vchart-extension/src/charts/timeline/series/interface.ts b/packages/vchart-extension/src/charts/timeline/series/interface.ts index 117f34ccab..ba9ed1d2a1 100644 --- a/packages/vchart-extension/src/charts/timeline/series/interface.ts +++ b/packages/vchart-extension/src/charts/timeline/series/interface.ts @@ -1,13 +1,4 @@ -import type { - ICartesianSeriesSpec, - ICartesianSeriesTheme, - IMarkSpec, - ISymbolMarkSpec, - ITextMarkSpec, - ILineMarkSpec, - ISeriesSpec -} from '@visactor/vchart'; -import type { TimelineLayoutType } from '../interface'; +import type { IMarkSpec, ISymbolMarkSpec, ITextMarkSpec, ILineMarkSpec, ICartesianSeriesSpec } from '@visactor/vchart'; /** horizontal 布局时的标题位置 */ export type HorizontalLabelPosition = 'top' | 'bottom' | 'top-bottom' | 'bottom-top'; @@ -18,16 +9,19 @@ export type VerticalLabelPosition = 'left' | 'right' | 'left-right' | 'right-lef /** 标题位置配置 */ export type LabelPosition = HorizontalLabelPosition | VerticalLabelPosition; -export interface IEventSeriesSpec extends ISeriesSpec { +export interface IEventSeriesSpec extends ICartesianSeriesSpec { type: 'event'; timeField?: string; eventField?: string; subTitleField?: string; seriesField?: string; dotTypeField?: string; - layoutType?: TimelineLayoutType; /** 标题和副标题的位置 */ labelPosition?: LabelPosition; + /** dot 和 label 之间的间距 */ + dotLabelGap?: number; + /** title 和 subTitle 之间的间距 */ + titleSubTitleGap?: number; name?: string; dot?: IMarkSpec; title?: IMarkSpec; @@ -35,4 +29,13 @@ export interface IEventSeriesSpec extends ISeriesSpec { line?: IMarkSpec; } -export type IEventSeriesTheme = ICartesianSeriesTheme; +export interface IEventSeriesTheme { + /** dot 和 label 之间的间距 */ + dotLabelGap?: number; + /** title 和 subTitle 之间的间距 */ + titleSubTitleGap?: number; + dot?: IMarkSpec; + title?: IMarkSpec; + subTitle?: IMarkSpec; + line?: IMarkSpec; +} diff --git a/packages/vchart-extension/src/charts/timeline/series/theme.ts b/packages/vchart-extension/src/charts/timeline/series/theme.ts new file mode 100644 index 0000000000..d10e9e26b3 --- /dev/null +++ b/packages/vchart-extension/src/charts/timeline/series/theme.ts @@ -0,0 +1,30 @@ +import type { IEventSeriesTheme } from './interface'; + +export const event: IEventSeriesTheme = { + dotLabelGap: 6, + titleSubTitleGap: 4, + dot: { + style: { + size: 8 + } + }, + line: { + visible: true, + style: { + stroke: '#c0c3c7', + lineWidth: 1 + } + }, + title: { + visible: true, + style: { + fontSize: 14 + } + }, + subTitle: { + visible: true, + style: { + fontSize: 12 + } + } +}; diff --git a/packages/vchart-extension/src/charts/timeline/timeline-transformer.ts b/packages/vchart-extension/src/charts/timeline/timeline-transformer.ts index 7037a496af..86cc2a2fb3 100644 --- a/packages/vchart-extension/src/charts/timeline/timeline-transformer.ts +++ b/packages/vchart-extension/src/charts/timeline/timeline-transformer.ts @@ -1,4 +1,4 @@ -import { BaseChartSpecTransformer } from '@visactor/vchart'; +import { BaseChartSpecTransformer, merge } from '@visactor/vchart'; import type { ICartesianAxisSpec, ICartesianLinearAxisSpec } from '@visactor/vchart'; import type { ITimelineChartSpec } from './interface'; @@ -23,34 +23,57 @@ export class TimelineChartSpecTransformer< transformSpec(spec: T): void { super.transformSpec(spec); this.transformSeriesSpec(spec); + + // 确定 direction(通过轴方向推断) const rawAxis = spec.axes?.[0]; - const axisType = rawAxis?.type ?? 'band'; const axisOrient = rawAxis?.orient; - let layoutType = spec.layoutType; - if (!layoutType) { - if (axisOrient === 'left' || axisOrient === 'right') { - layoutType = 'vertical'; - } else if (axisOrient === 'bottom' || axisOrient === 'top') { - layoutType = 'horizontal'; - } - } - if (!layoutType) { - layoutType = 'horizontal'; + + // 默认为 horizontal + let direction: 'horizontal' | 'vertical' = 'horizontal'; + if (axisOrient === 'left' || axisOrient === 'right') { + direction = 'vertical'; + } else if (axisOrient === 'bottom' || axisOrient === 'top') { + direction = 'horizontal'; } - spec.layoutType = layoutType; - const defaultOrient = layoutType === 'vertical' ? 'left' : 'bottom'; - const allowedOrients = layoutType === 'vertical' ? ['left', 'right'] : ['bottom', 'right']; + // 确定默认的轴方向和类型 + const defaultOrient = direction === 'vertical' ? 'left' : 'bottom'; + const allowedOrients = direction === 'vertical' ? ['left', 'right'] : ['bottom', 'top']; const orientNormalized: ICartesianAxisSpec['orient'] = allowedOrients.includes(axisOrient ?? '') ? (axisOrient as ICartesianAxisSpec['orient']) : (defaultOrient as ICartesianAxisSpec['orient']); + + // 确定轴类型,默认为 band + const axisType = rawAxis?.type ?? 'band'; const typeNormalized: ICartesianAxisSpec['type'] = axisType === 'linear' || axisType === 'time' || axisType === 'band' ? (axisType as any) : 'band'; - const baseAxis: ICartesianAxisSpec = { - ...((rawAxis ?? {}) as ICartesianAxisSpec), - orient: orientNormalized, - type: typeNormalized - }; + + // 构建轴配置 + const baseAxis: ICartesianAxisSpec = merge( + { + label: { + visible: false + }, + tick: { + visible: false + }, + grid: { + visible: false + }, + domainLine: { + visible: false + } + }, + { + ...((rawAxis ?? {}) as ICartesianAxisSpec), + orient: orientNormalized, + type: typeNormalized + } + ); + + // 检查是否有 seriesField,如果有则需要创建第二个分类轴 + const hasSeriesField = spec.series?.some(s => s.seriesField); + if (baseAxis.type === 'linear') { const linearAxis: ICartesianLinearAxisSpec = { ...(baseAxis as ICartesianLinearAxisSpec), @@ -61,9 +84,62 @@ export class TimelineChartSpecTransformer< spec.axes = [baseAxis]; } + // 如果有 seriesField,需要创建第二个分类轴 + if (hasSeriesField) { + const categoryAxisOrient = direction === 'vertical' ? 'bottom' : 'left'; + const categoryAxis: ICartesianAxisSpec = { + orient: categoryAxisOrient, + type: 'band', + label: { + visible: false + }, + tick: { + visible: false + }, + grid: { + visible: false + }, + domainLine: { + visible: false + } + }; + + // 将分类轴添加到轴列表中 + if (direction === 'vertical') { + // vertical: 时间轴在前,分类轴在后 + spec.axes = [spec.axes[0], categoryAxis]; + } else { + // horizontal: 时间轴在前,分类轴在后 + spec.axes = [spec.axes[0], categoryAxis]; + } + } + + // 将 direction 传递给 series,并设置 xField/yField 以便轴系统收集数据 spec.series?.forEach(seriesSpec => { - if (!seriesSpec.layoutType && spec.layoutType) { - seriesSpec.layoutType = spec.layoutType; + if (!seriesSpec.direction) { + seriesSpec.direction = direction; + } + + // 根据 direction 将 timeField 映射到 xField 或 yField + // 这样轴系统才能正确收集数据 + if (direction === 'vertical') { + // vertical 布局:y 轴是时间方向 + if (seriesSpec.timeField && !seriesSpec.yField) { + seriesSpec.yField = seriesSpec.timeField; + } + // x 轴是分类方向(如果没有 seriesField,创建一个虚拟字段) + if (!seriesSpec.xField) { + seriesSpec.xField = seriesSpec.seriesField || '__vchart_timeline_dummy__'; + } + } else { + // horizontal 布局:x 轴是时间方向 + if (seriesSpec.timeField && !seriesSpec.xField) { + seriesSpec.xField = seriesSpec.timeField; + } + // y 轴是分类方向(如果没有 seriesField,创建一个虚拟字段) + if (!seriesSpec.yField) { + seriesSpec.yField = seriesSpec.seriesField || '__vchart_timeline_dummy__'; + } } }); } From c48ff36fa373b1c07dd8be1aac37f22c6b0e8275 Mon Sep 17 00:00:00 2001 From: xile611 Date: Mon, 2 Feb 2026 11:38:10 +0800 Subject: [PATCH 3/7] feat: add arrowMark to timeline --- .../enterprise-development-horizontal.ts | 170 ++++++++++++++++ .../timeline/enterprise-development.ts | 189 ++++++++++++++++++ .../charts/timeline/series/event-series.ts | 141 ++++++++++++- .../src/charts/timeline/series/interface.ts | 18 +- .../src/charts/timeline/series/theme.ts | 4 + .../charts/timeline/timeline-transformer.ts | 6 + 6 files changed, 517 insertions(+), 11 deletions(-) create mode 100644 packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/enterprise-development-horizontal.ts create mode 100644 packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/enterprise-development.ts diff --git a/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/enterprise-development-horizontal.ts b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/enterprise-development-horizontal.ts new file mode 100644 index 0000000000..b8a59d4f37 --- /dev/null +++ b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/enterprise-development-horizontal.ts @@ -0,0 +1,170 @@ +import { VChart, type Datum } from '@visactor/vchart'; +import { registerTimelineChart } from '../../../../../src'; +import type { ITimelineChartSpec } from '../../../../../src/charts/timeline'; + +const timelineData = [ + { + id: '1', + year: '2018年', + step: '01', + title: '企业成立', + detail: '完成初期团队搭建和产品定位', + time: 1, + color: '#5B8FF9' + }, + { + id: '2', + year: '2020年', + step: '02', + title: '发布首款核心产品', + detail: '打开区域市场', + time: 2, + color: '#5AD8A6' + }, + { + id: '3', + year: '2021年', + step: '03', + title: '启动数字化平台', + detail: '提升内部运营效率', + time: 3, + color: '#E8684A' + }, + { + id: '4', + year: '2022年', + step: '04', + title: '完成A轮融资', + detail: '加速市场拓展布局', + time: 4, + color: '#9270CA' + }, + { + id: '5', + year: '2024年', + step: '05', + title: '推进生态合作', + detail: '拓展全国影响力', + time: 5, + color: '#6C5DD3' + } +]; + +const getDatumString = (datum: Datum | undefined, key: string) => { + if (!datum || typeof datum !== 'object') { + return ''; + } + const value = (datum as Record)[key]; + return typeof value === 'string' ? value : ''; +}; + +const spec: ITimelineChartSpec = { + type: 'timeline', + name: 'enterprise-development', + direction: 'horizontal', + padding: { + left: 200, + right: 200, + top: 120, + bottom: 80 + }, + background: '#f5f5f5', + data: [ + { + id: 'timeline-data', + values: timelineData + } + ], + title: { + visible: true, + text: '企业发展时间线', + subtext: '展示企业在关键年份的战略动作与发展节点', + textStyle: { + fontSize: 32, + fontWeight: 'bold', + fill: '#1a1a1a' + } + }, + series: [ + { + type: 'event', + dataId: 'timeline-data', + timeField: 'time', + eventField: 'year', + subTitleField: 'title', + labelPosition: 'right', + arrow: { + visible: true + }, + dot: { + style: { + size: 20, + fill: (datum: Datum) => String((datum as Record).color ?? '#5B8FF9'), + lineWidth: 4, + stroke: '#ffffff' + } + }, + line: { + style: { + stroke: '#d9d9d9', + lineWidth: 4 + } + }, + title: { + style: { + fill: '#1a1a1a', + fontSize: 18, + fontWeight: 600 + } + }, + subTitle: { + style: { + fill: '#1a1a1a', + fontSize: 15, + fontWeight: 'normal' + } + } + } + ], + tooltip: { + mark: { + title: { + value: (datum?: Datum) => getDatumString(datum, 'year') + }, + content: [ + { + key: '阶段', + value: (datum?: Datum) => getDatumString(datum, 'step') + }, + { + key: '标题', + value: (datum?: Datum) => getDatumString(datum, 'title') + }, + { + key: '详情', + value: (datum?: Datum) => getDatumString(datum, 'detail') + } + ] + } + } +}; + +declare global { + interface Window { + vchartH?: VChart; + } +} + +const run = () => { + registerTimelineChart(); + const cs = new VChart(spec, { + dom: document.getElementById('chart') as HTMLElement, + onError: err => { + console.error(err); + } + }); + cs.renderSync(); + window.vchartH = cs; +}; + +run(); diff --git a/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/enterprise-development.ts b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/enterprise-development.ts new file mode 100644 index 0000000000..e5b895797f --- /dev/null +++ b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/enterprise-development.ts @@ -0,0 +1,189 @@ +import { VChart, type Datum } from '@visactor/vchart'; +import { registerTimelineChart } from '../../../../../src'; +import type { ITimelineChartSpec } from '../../../../../src/charts/timeline'; + +const timelineData = [ + { + id: '1', + year: '2018年', + step: '01', + title: '企业成立', + detail: '完成初期团队搭建和产品定位', + time: 1, + color: '#5B8FF9' + }, + { + id: '2', + year: '2020年', + step: '02', + title: '发布首款核心产品', + detail: '打开区域市场', + time: 2, + color: '#5AD8A6' + }, + { + id: '3', + year: '2021年', + step: '03', + title: '启动数字化平台', + detail: '提升内部运营效率', + time: 3, + color: '#E8684A' + }, + { + id: '4', + year: '2022年', + step: '04', + title: '完成A轮融资', + detail: '加速市场拓展布局', + time: 4, + color: '#9270CA' + }, + { + id: '5', + year: '2024年', + step: '05', + title: '推进生态合作', + detail: '拓展全国影响力', + time: 5, + color: '#6C5DD3' + } +]; + +const getDatumString = (datum: Datum | undefined, key: string) => { + if (!datum || typeof datum !== 'object') { + return ''; + } + const value = (datum as Record)[key]; + return typeof value === 'string' ? value : ''; +}; + +const spec: ITimelineChartSpec = { + type: 'timeline', + name: 'enterprise-development', + direction: 'vertical', + padding: { + left: 200, + right: 200, + top: 120, + bottom: 80 + }, + background: '#f5f5f5', + data: [ + { + id: 'timeline-data', + values: timelineData + } + ], + title: { + visible: true, + text: '企业发展时间线', + subtext: '展示企业在关键年份的战略动作与发展节点', + textStyle: { + fontSize: 32, + fontWeight: 'bold', + fill: '#1a1a1a' + } + }, + axes: [ + { + orient: 'left', + type: 'band', + // inverse: true, + label: { + visible: false + }, + tick: { + visible: false + }, + grid: { + visible: false + }, + domainLine: { + visible: false + } + } + ], + series: [ + { + type: 'event', + dataId: 'timeline-data', + timeField: 'time', + eventField: 'year', + subTitleField: 'title', + labelPosition: 'right', + arrow: { + visible: false + }, + dot: { + style: { + size: 20, + fill: (datum: Datum) => String((datum as Record).color ?? '#5B8FF9'), + lineWidth: 4, + stroke: '#ffffff' + } + }, + line: { + style: { + stroke: '#d9d9d9', + lineWidth: 4 + } + }, + title: { + style: { + fill: '#1a1a1a', + fontSize: 18, + fontWeight: 600 + } + }, + subTitle: { + style: { + fill: '#1a1a1a', + fontSize: 15, + fontWeight: 'normal' + } + } + } + ], + tooltip: { + mark: { + title: { + value: (datum?: Datum) => getDatumString(datum, 'year') + }, + content: [ + { + key: '阶段', + value: (datum?: Datum) => getDatumString(datum, 'step') + }, + { + key: '标题', + value: (datum?: Datum) => getDatumString(datum, 'title') + }, + { + key: '详情', + value: (datum?: Datum) => getDatumString(datum, 'detail') + } + ] + } + } +}; + +declare global { + interface Window { + vchartVertical?: VChart; + } +} + +const run = () => { + registerTimelineChart(); + const cs = new VChart(spec, { + dom: document.getElementById('chart') as HTMLElement, + onError: err => { + console.error(err); + } + }); + cs.renderSync(); + window.vchartVertical = cs; +}; + +run(); diff --git a/packages/vchart-extension/src/charts/timeline/series/event-series.ts b/packages/vchart-extension/src/charts/timeline/series/event-series.ts index b7aaac6de7..a50065e899 100644 --- a/packages/vchart-extension/src/charts/timeline/series/event-series.ts +++ b/packages/vchart-extension/src/charts/timeline/series/event-series.ts @@ -9,6 +9,7 @@ import { registerSymbolMark, registerTextMark, registerLineMark, + registerPathMark, STATE_VALUE_ENUM, type Datum, type IMark, @@ -32,7 +33,8 @@ const eventSeriesMark: SeriesMarkMap = { [SeriesMarkNameEnum.line]: { name: SeriesMarkNameEnum.line, type: MarkTypeEnum.line }, [SeriesMarkNameEnum.dot]: { name: SeriesMarkNameEnum.dot, type: MarkTypeEnum.symbol }, [SeriesMarkNameEnum.title]: { name: SeriesMarkNameEnum.title, type: MarkTypeEnum.text }, - [SeriesMarkNameEnum.subTitle]: { name: SeriesMarkNameEnum.subTitle, type: MarkTypeEnum.text } + [SeriesMarkNameEnum.subTitle]: { name: SeriesMarkNameEnum.subTitle, type: MarkTypeEnum.text }, + arrow: { name: 'arrow', type: MarkTypeEnum.path } }; export class EventSeries extends CartesianSeries { @@ -52,6 +54,7 @@ export class EventSeries extends private _titleMark?: IMark; private _subTitleMark?: IMark; private _axisLineMark?: IMark; + private _arrowMark?: IMark; private _timeField?: string; private _eventField?: string; @@ -89,6 +92,10 @@ export class EventSeries extends isSeriesMark: true }); + this._arrowMark = this._createMark(EventSeries.mark.arrow, { + isSeriesMark: true + }); + this._titleMark = this._createMark(EventSeries.mark.title); this._subTitleMark = this._createMark(EventSeries.mark.subTitle); @@ -140,6 +147,18 @@ export class EventSeries extends ); } + if (this._arrowMark) { + this.setMarkStyle( + this._arrowMark, + { + path: (datum: Datum) => this._getArrowPath(datum), + fill: dotSpec?.style?.fill ?? this.getColorAttribute() + }, + STATE_VALUE_ENUM.STATE_NORMAL, + AttributeLevel.Series + ); + } + if (this._titleMark) { this.setMarkStyle( this._titleMark, @@ -231,7 +250,7 @@ export class EventSeries extends private _getLabelTextBaseline(datum: Datum, isTitle: boolean): 'top' | 'bottom' | 'middle' { if (this.direction === 'vertical') { // vertical 布局时:title 用 top,subTitle 用 top - return 'top'; + return 'middle'; } const side = this._getLabelSide(datum); return side === 'primary' ? 'bottom' : 'top'; @@ -399,6 +418,119 @@ export class EventSeries extends return datum === firstInGroup; } + private _getNextDatum(datum: Datum): Datum | null { + const data = this._getViewDataList(); + const currentIndex = data.indexOf(datum); + + if (currentIndex === -1 || currentIndex === data.length - 1) { + return null; + } + + if (!this._seriesField) { + return data[currentIndex + 1]; + } + + const categoryValue = (datum as Record)[this._seriesField]; + + // 在同一分组中找到下一个数据 + for (let i = currentIndex + 1; i < data.length; i++) { + const nextDatum = data[i]; + if ((nextDatum as Record)[this._seriesField] === categoryValue) { + return nextDatum; + } + } + + return null; + } + + private _getPreviousDatum(datum: Datum): Datum | null { + const data = this._getViewDataList(); + const currentIndex = data.indexOf(datum); + + if (currentIndex === -1 || currentIndex === 0) { + return null; + } + + if (!this._seriesField) { + return data[currentIndex - 1]; + } + + const categoryValue = (datum as Record)[this._seriesField]; + + // 在同一分组中找到上一个数据 + for (let i = currentIndex - 1; i >= 0; i--) { + const prevDatum = data[i]; + if ((prevDatum as Record)[this._seriesField] === categoryValue) { + return prevDatum; + } + } + + return null; + } + + private _getArrowPath(datum: Datum): string { + const point = this._getPoint(datum); + const nextDatum = this._getNextDatum(datum); + const prevDatum = this._getPreviousDatum(datum); + + const arrowThickness = this._spec.arrow?.thickness ?? 16; + const rect = this._region.getLayoutRect(); + + if (this.direction === 'vertical') { + const axisHelper = this.getYAxisHelper(); + const inverse = axisHelper.isInverse(); + const startPoint = prevDatum + ? { + x: point.x, + y: (this._getPoint(prevDatum).y + point.y) / 2 + } + : { x: point.x, y: inverse ? 0 : rect.height }; + const endPoint = nextDatum + ? { + x: point.x, + y: (this._getPoint(nextDatum).y + point.y) / 2 + } + : { x: point.x, y: inverse ? rect.height : 0 }; + const tag = inverse ? 1 : -1; + + const arrowHeight = arrowThickness / 3; + const arrowWidth = arrowThickness / 2; + + return `M ${startPoint.x - arrowWidth} ${startPoint.y} L ${startPoint.x} ${startPoint.y + tag * arrowHeight} L ${ + startPoint.x + arrowWidth + } ${startPoint.y} + L ${endPoint.x + arrowWidth} ${endPoint.y - tag * arrowHeight} + L ${endPoint.x} ${endPoint.y} + L ${endPoint.x - arrowWidth} ${endPoint.y - tag * arrowHeight} Z`; + } + + const axisHelper = this.getXAxisHelper(); + const inverse = axisHelper.isInverse(); + const startPoint = prevDatum + ? { + x: (this._getPoint(prevDatum).x + point.x) / 2, + y: point.y + } + : { x: inverse ? rect.width : 0, y: point.y }; + const endPoint = nextDatum + ? { + x: (this._getPoint(nextDatum).x + point.x) / 2, + y: point.y + } + : { x: inverse ? 0 : rect.width, y: point.y }; + const tag = inverse ? -1 : 1; + + const arrowHeight = arrowThickness / 2; + const arrowWidth = arrowThickness / 3; + + return `M ${startPoint.x} ${startPoint.y - arrowHeight} L ${startPoint.x + tag * arrowWidth} ${startPoint.y} L ${ + startPoint.x + } ${startPoint.y + arrowHeight} + L ${endPoint.x - tag * arrowWidth} ${endPoint.y + arrowHeight} + L ${endPoint.x} ${endPoint.y} + L ${endPoint.x - tag * arrowWidth} ${endPoint.y - arrowHeight} Z`; + } + private _getDatumString(datum: Datum | undefined, field?: string): string { if (!datum || !field) { return ''; @@ -423,7 +555,9 @@ export class EventSeries extends } getActiveMarks(): IMark[] { - return [this._axisLineMark, this._dotMark, this._titleMark, this._subTitleMark].filter(Boolean) as IMark[]; + return [this._axisLineMark, this._dotMark, this._arrowMark, this._titleMark, this._subTitleMark].filter( + Boolean + ) as IMark[]; } } @@ -431,5 +565,6 @@ export const registerEventSeries = () => { registerSymbolMark(); registerTextMark(); registerLineMark(); + registerPathMark(); Factory.registerSeries(EventSeries.type, EventSeries); }; diff --git a/packages/vchart-extension/src/charts/timeline/series/interface.ts b/packages/vchart-extension/src/charts/timeline/series/interface.ts index ba9ed1d2a1..463b5d249c 100644 --- a/packages/vchart-extension/src/charts/timeline/series/interface.ts +++ b/packages/vchart-extension/src/charts/timeline/series/interface.ts @@ -1,4 +1,11 @@ -import type { IMarkSpec, ISymbolMarkSpec, ITextMarkSpec, ILineMarkSpec, ICartesianSeriesSpec } from '@visactor/vchart'; +import type { + IMarkSpec, + ISymbolMarkSpec, + ITextMarkSpec, + ILineMarkSpec, + IPathMarkSpec, + ICartesianSeriesSpec +} from '@visactor/vchart'; /** horizontal 布局时的标题位置 */ export type HorizontalLabelPosition = 'top' | 'bottom' | 'top-bottom' | 'bottom-top'; @@ -9,24 +16,18 @@ export type VerticalLabelPosition = 'left' | 'right' | 'left-right' | 'right-lef /** 标题位置配置 */ export type LabelPosition = HorizontalLabelPosition | VerticalLabelPosition; -export interface IEventSeriesSpec extends ICartesianSeriesSpec { +export interface IEventSeriesSpec extends ICartesianSeriesSpec, IEventSeriesTheme { type: 'event'; timeField?: string; eventField?: string; subTitleField?: string; seriesField?: string; - dotTypeField?: string; /** 标题和副标题的位置 */ labelPosition?: LabelPosition; /** dot 和 label 之间的间距 */ dotLabelGap?: number; /** title 和 subTitle 之间的间距 */ titleSubTitleGap?: number; - name?: string; - dot?: IMarkSpec; - title?: IMarkSpec; - subTitle?: IMarkSpec; - line?: IMarkSpec; } export interface IEventSeriesTheme { @@ -38,4 +39,5 @@ export interface IEventSeriesTheme { title?: IMarkSpec; subTitle?: IMarkSpec; line?: IMarkSpec; + arrow?: IMarkSpec & { thickness?: number }; } diff --git a/packages/vchart-extension/src/charts/timeline/series/theme.ts b/packages/vchart-extension/src/charts/timeline/series/theme.ts index d10e9e26b3..fb4088c2cc 100644 --- a/packages/vchart-extension/src/charts/timeline/series/theme.ts +++ b/packages/vchart-extension/src/charts/timeline/series/theme.ts @@ -26,5 +26,9 @@ export const event: IEventSeriesTheme = { style: { fontSize: 12 } + }, + arrow: { + visible: false, + thickness: 16 } }; diff --git a/packages/vchart-extension/src/charts/timeline/timeline-transformer.ts b/packages/vchart-extension/src/charts/timeline/timeline-transformer.ts index 86cc2a2fb3..f13db7d8d7 100644 --- a/packages/vchart-extension/src/charts/timeline/timeline-transformer.ts +++ b/packages/vchart-extension/src/charts/timeline/timeline-transformer.ts @@ -64,6 +64,12 @@ export class TimelineChartSpecTransformer< visible: false } }, + orientNormalized === 'left' || orientNormalized === 'right' + ? { + inverse: true + } + : {}, + typeNormalized === 'band' ? { paddingInner: 0, paddingOuter: 0 } : {}, { ...((rawAxis ?? {}) as ICartesianAxisSpec), orient: orientNormalized, From a19a800219156924dca70384f1518fb338813a67 Mon Sep 17 00:00:00 2001 From: xile611 Date: Mon, 2 Feb 2026 12:21:06 +0800 Subject: [PATCH 4/7] feat: support iconMark in timeline --- .../test-page/timeline/icon-demo-vertical.ts | 146 ++++++++++++++++++ .../browser/test-page/timeline/icon-demo.ts | 145 +++++++++++++++++ .../charts/timeline/series/event-series.ts | 98 +++++++++--- .../src/charts/timeline/series/interface.ts | 15 +- .../src/charts/timeline/series/theme.ts | 10 +- 5 files changed, 377 insertions(+), 37 deletions(-) create mode 100644 packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/icon-demo-vertical.ts create mode 100644 packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/icon-demo.ts diff --git a/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/icon-demo-vertical.ts b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/icon-demo-vertical.ts new file mode 100644 index 0000000000..9fd76d90a4 --- /dev/null +++ b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/icon-demo-vertical.ts @@ -0,0 +1,146 @@ +import { VChart, type Datum } from '@visactor/vchart'; +import { registerTimelineChart } from '../../../../../src'; +import type { ITimelineChartSpec } from '../../../../../src/charts/timeline'; + +/** + * Icon 功能演示 - 垂直布局示例 + * 展示垂直布局时 iconMark 与 title 关于 lineMark 对称的效果 + */ + +const timelineData = [ + { + id: '1', + year: '2021', + title: '产品发布', + detail: '发布第一代产品,获得市场认可', + icon: 'star', + time: 1, + color: '#4A90E2' + }, + { + id: '2', + year: '2022', + title: '技术突破', + detail: '核心技术获得重大突破', + icon: 'triangleUp', + time: 2, + color: '#50C8C8' + }, + { + id: '3', + year: '2023', + title: '市场扩展', + detail: '业务覆盖全国主要城市', + icon: 'diamond', + time: 3, + color: '#F5A623' + }, + { + id: '4', + year: '2024', + title: '国际化', + detail: '进军国际市场,开启新篇章', + icon: 'cross', + time: 4, + color: '#9B59B6' + } +]; + +const spec: ITimelineChartSpec = { + type: 'timeline', + name: 'icon-demo-vertical', + direction: 'vertical', + padding: { + left: 200, + right: 200, + top: 60, + bottom: 60 + }, + background: '#f5f5f5', + data: [ + { + id: 'timeline-data', + values: timelineData + } + ], + title: { + visible: true, + text: 'Icon 功能演示 - 垂直布局', + subtext: 'iconMark 与 title 关于 lineMark 对称显示', + style: { + fontSize: 24, + fontWeight: 'bold', + fill: '#333' + }, + subtextStyle: { + fontSize: 14, + fill: '#666' + } + }, + series: [ + { + type: 'event', + dataId: 'timeline-data', + timeField: 'time', + eventField: 'title', + subTitleField: 'detail', + iconField: 'icon', + labelPosition: 'left-right', + dotLabelGap: 8, + dot: { + style: { + size: 10, + fill: (datum: Datum) => String((datum as Record).color ?? '#4A90E2'), + stroke: '#fff', + lineWidth: 2 + } + }, + icon: { + style: { + size: 24, + fill: (datum: Datum) => String((datum as Record).color ?? '#4A90E2') + } + }, + title: { + style: { + fill: '#333', + fontSize: 14, + fontWeight: 'bold' + } + }, + subTitle: { + style: { + fill: '#666', + fontSize: 12, + lineHeight: 18 + } + }, + line: { + style: { + stroke: '#c0c3c7', + lineWidth: 2 + } + } + } + ] +}; + +declare global { + interface Window { + vchart?: VChart; + } +} + +const run = () => { + registerTimelineChart(); + const cs = new VChart(spec, { + dom: document.getElementById('chart') as HTMLElement, + onError: err => { + console.error(err); + } + }); + cs.renderSync(); + window.vchart = cs; +}; + +run(); diff --git a/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/icon-demo.ts b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/icon-demo.ts new file mode 100644 index 0000000000..327a9cb992 --- /dev/null +++ b/packages/vchart-extension/__tests__/runtime/browser/test-page/timeline/icon-demo.ts @@ -0,0 +1,145 @@ +import { VChart, type Datum } from '@visactor/vchart'; +import { registerTimelineChart } from '../../../../../src'; +import type { ITimelineChartSpec } from '../../../../../src/charts/timeline'; + +/** + * Icon 功能演示 - 水平布局示例 + * 展示 iconMark 与 title 关于 lineMark 对称的效果 + */ + +const timelineData = [ + { + id: '1', + year: '2021', + title: '产品发布', + detail: '发布第一代产品,获得市场认可', + icon: 'star', + time: 1, + color: '#4A90E2' + }, + { + id: '2', + year: '2022', + title: '技术突破', + detail: '核心技术获得重大突破', + icon: 'triangleUp', + time: 2, + color: '#50C8C8' + }, + { + id: '3', + year: '2023', + title: '市场扩展', + detail: '业务覆盖全国主要城市', + icon: 'diamond', + time: 3, + color: '#F5A623' + }, + { + id: '4', + year: '2024', + title: '国际化', + detail: '进军国际市场,开启新篇章', + icon: 'cross', + time: 4, + color: '#9B59B6' + } +]; + +const spec: ITimelineChartSpec = { + type: 'timeline', + name: 'icon-demo', + direction: 'horizontal', + padding: { + left: 60, + right: 60, + top: 120, + bottom: 120 + }, + background: '#f5f5f5', + data: [ + { + id: 'timeline-data', + values: timelineData + } + ], + title: { + visible: true, + text: 'Icon 功能演示', + subtext: 'iconMark 与 title 关于 lineMark 对称显示', + style: { + fontSize: 24, + fontWeight: 'bold', + fill: '#333' + }, + subtextStyle: { + fontSize: 14, + fill: '#666' + } + }, + series: [ + { + type: 'event', + dataId: 'timeline-data', + timeField: 'time', + eventField: 'title', + subTitleField: 'detail', + labelPosition: 'top-bottom', + dotLabelGap: 8, + dot: { + style: { + size: 10, + fill: (datum: Datum) => String((datum as Record).color ?? '#4A90E2'), + stroke: '#fff', + lineWidth: 2 + } + }, + icon: { + style: { + size: 24, + fill: (datum: Datum) => String((datum as Record).color ?? '#4A90E2') + } + }, + title: { + style: { + fill: '#333', + fontSize: 14, + fontWeight: 'bold' + } + }, + subTitle: { + style: { + fill: '#666', + fontSize: 12, + lineHeight: 18 + } + }, + line: { + style: { + stroke: '#c0c3c7', + lineWidth: 2 + } + } + } + ] +}; + +declare global { + interface Window { + vchart?: VChart; + } +} + +const run = () => { + registerTimelineChart(); + const cs = new VChart(spec, { + dom: document.getElementById('chart') as HTMLElement, + onError: err => { + console.error(err); + } + }); + cs.renderSync(); + window.vchart = cs; +}; + +run(); diff --git a/packages/vchart-extension/src/charts/timeline/series/event-series.ts b/packages/vchart-extension/src/charts/timeline/series/event-series.ts index a50065e899..1659e2f27a 100644 --- a/packages/vchart-extension/src/charts/timeline/series/event-series.ts +++ b/packages/vchart-extension/src/charts/timeline/series/event-series.ts @@ -20,6 +20,7 @@ import { import { EVENT_SERIES_TYPE } from './constant'; import type { IEventSeriesSpec, LabelPosition } from './interface'; import { event } from './theme'; +import { isValid } from '@visactor/vutils'; type AxisHelper = { isContinuous?: boolean; @@ -28,14 +29,15 @@ type AxisHelper = { valueToPosition?: (value: unknown) => number; }; -const eventSeriesMark: SeriesMarkMap = { +const eventSeriesMark = { ...baseSeriesMark, [SeriesMarkNameEnum.line]: { name: SeriesMarkNameEnum.line, type: MarkTypeEnum.line }, [SeriesMarkNameEnum.dot]: { name: SeriesMarkNameEnum.dot, type: MarkTypeEnum.symbol }, + icon: { name: 'icon', type: MarkTypeEnum.symbol }, [SeriesMarkNameEnum.title]: { name: SeriesMarkNameEnum.title, type: MarkTypeEnum.text }, [SeriesMarkNameEnum.subTitle]: { name: SeriesMarkNameEnum.subTitle, type: MarkTypeEnum.text }, arrow: { name: 'arrow', type: MarkTypeEnum.path } -}; +} as any; export class EventSeries extends CartesianSeries { static readonly type: string = EVENT_SERIES_TYPE; @@ -51,6 +53,7 @@ export class EventSeries extends protected declare _spec: T; private _dotMark?: IMark; + private _iconMark?: IMark; private _titleMark?: IMark; private _subTitleMark?: IMark; private _axisLineMark?: IMark; @@ -59,9 +62,8 @@ export class EventSeries extends private _timeField?: string; private _eventField?: string; private _subTitleField?: string; + private _iconField?: string; private _labelPosition?: LabelPosition; - private _dotLabelGap?: number; - private _titleSubTitleGap?: number; setAttrFromSpec(): void { super.setAttrFromSpec(); @@ -69,9 +71,8 @@ export class EventSeries extends this._timeField = this._spec.timeField as string | undefined; this._eventField = this._spec.eventField; this._subTitleField = this._spec.subTitleField; + this._iconField = this._spec.iconField; this._labelPosition = this._spec.labelPosition; - this._dotLabelGap = this._spec.dotLabelGap ?? 6; - this._titleSubTitleGap = this._spec.titleSubTitleGap ?? 4; } getDimensionField(): string[] { @@ -92,7 +93,11 @@ export class EventSeries extends isSeriesMark: true }); - this._arrowMark = this._createMark(EventSeries.mark.arrow, { + this._iconMark = this._createMark((EventSeries.mark as any).icon, { + isSeriesMark: true + }); + + this._arrowMark = this._createMark((EventSeries.mark as any).arrow, { isSeriesMark: true }); @@ -116,22 +121,19 @@ export class EventSeries extends ); } - const dotSpec = this._spec.dot as { size?: number; style?: { size?: number; fill?: unknown } } | undefined; - const dotSize = - typeof dotSpec?.style?.size === 'number' - ? dotSpec.style.size - : typeof dotSpec?.size === 'number' - ? dotSpec.size - : 8; + const dotSpec = this._spec.dot as { style?: { size?: number; fill?: unknown } } | undefined; + const dotSize = typeof dotSpec?.style?.size === 'number' ? dotSpec.style.size : 8; - const titleStyle = this._spec.title?.style ?? {}; - const subTitleStyle = this._spec.subTitle?.style ?? {}; + const titleSpec = this._spec.title; + const titleStyle = titleSpec?.style ?? {}; + const subTitleSpec = this._spec.subTitle; + const subTitleStyle = subTitleSpec?.style ?? {}; // 获取 title 字体大小,用于计算 subTitle 的位置 const titleFontSize = typeof titleStyle.fontSize === 'number' ? titleStyle.fontSize : 14; // dot 和 label 之间的间距 - const labelOffset = dotSize / 2 + (this._dotLabelGap ?? 6); + const labelOffset = dotSize / 2 + (titleSpec?.offset ?? 6); if (this._dotMark) { this.setMarkStyle( @@ -191,6 +193,27 @@ export class EventSeries extends AttributeLevel.Series ); } + + // icon 和 line 之间的间距 + + const iconSpec = this._spec.icon as { offset?: number; style?: { size?: number; fill?: unknown } } | undefined; + const iconSize = typeof iconSpec?.style?.size === 'number' ? iconSpec.style.size : 20; + const iconOffset = (isValid(iconSpec?.offset) ? iconSpec.offset + dotSize / 2 : labelOffset) + iconSize / 2; + + if (this._iconMark) { + this.setMarkStyle( + this._iconMark, + { + x: (datum: Datum) => this._getIconPosition(datum, iconOffset).x, + y: (datum: Datum) => this._getIconPosition(datum, iconOffset).y, + size: iconSize, + fill: iconSpec?.style?.fill ?? this.getColorAttribute(), + shape: (datum: Datum) => this._getDatumString(datum, this._iconField) || 'circle' + }, + STATE_VALUE_ENUM.STATE_NORMAL, + AttributeLevel.Series + ); + } } /** @@ -283,6 +306,7 @@ export class EventSeries extends private _getSubTitlePosition(datum: Datum, offset: number, titleFontSize: number): IPoint { const point = this._getPoint(datum); const side = this._getLabelSide(datum); + const gap = this._spec.title?.subTitleGap ?? 4; if (this.direction === 'vertical') { // vertical: left/right,subTitle 在 title 下方 @@ -292,16 +316,35 @@ export class EventSeries extends return { x: side === 'primary' ? point.x - offset : point.x + offset, - y: point.y + titleLineHeight + this._titleSubTitleGap + y: point.y + titleLineHeight + gap }; } // horizontal: top/bottom return { x: point.x, - y: - side === 'primary' - ? point.y - (offset + titleFontSize + this._titleSubTitleGap) - : point.y + (offset + titleFontSize + this._titleSubTitleGap) + y: side === 'primary' ? point.y - (offset + titleFontSize + gap) : point.y + (offset + titleFontSize + gap) + }; + } + + /** + * 获取 icon 的位置 + * icon 与 title 关于 lineMark 对称 + */ + private _getIconPosition(datum: Datum, offset: number): IPoint { + const point = this._getPoint(datum); + const side = this._getLabelSide(datum); + + if (this.direction === 'vertical') { + // vertical: 当 title 在 right 时,icon 在 left;当 title 在 left 时,icon 在 right + return { + x: side === 'primary' ? point.x + offset : point.x - offset, + y: point.y + }; + } + // horizontal: 当 title 在 top 时,icon 在 bottom;当 title 在 bottom 时,icon 在 top + return { + x: point.x, + y: side === 'primary' ? point.y + offset : point.y - offset }; } @@ -555,9 +598,14 @@ export class EventSeries extends } getActiveMarks(): IMark[] { - return [this._axisLineMark, this._dotMark, this._arrowMark, this._titleMark, this._subTitleMark].filter( - Boolean - ) as IMark[]; + return [ + this._axisLineMark, + this._dotMark, + this._iconMark, + this._arrowMark, + this._titleMark, + this._subTitleMark + ].filter(Boolean) as IMark[]; } } diff --git a/packages/vchart-extension/src/charts/timeline/series/interface.ts b/packages/vchart-extension/src/charts/timeline/series/interface.ts index 463b5d249c..4a89b80eab 100644 --- a/packages/vchart-extension/src/charts/timeline/series/interface.ts +++ b/packages/vchart-extension/src/charts/timeline/series/interface.ts @@ -22,22 +22,17 @@ export interface IEventSeriesSpec extends ICartesianSeriesSpec, IEventSeriesThem eventField?: string; subTitleField?: string; seriesField?: string; + /** icon 字段名,用于显示图标或图片 */ + iconField?: string; /** 标题和副标题的位置 */ labelPosition?: LabelPosition; - /** dot 和 label 之间的间距 */ - dotLabelGap?: number; - /** title 和 subTitle 之间的间距 */ - titleSubTitleGap?: number; } export interface IEventSeriesTheme { - /** dot 和 label 之间的间距 */ - dotLabelGap?: number; - /** title 和 subTitle 之间的间距 */ - titleSubTitleGap?: number; dot?: IMarkSpec; - title?: IMarkSpec; - subTitle?: IMarkSpec; + icon?: IMarkSpec & { offset?: number }; + title?: IMarkSpec & { subTitleGap?: number; offset?: number }; + subTitle?: IMarkSpec & { offset?: number }; line?: IMarkSpec; arrow?: IMarkSpec & { thickness?: number }; } diff --git a/packages/vchart-extension/src/charts/timeline/series/theme.ts b/packages/vchart-extension/src/charts/timeline/series/theme.ts index fb4088c2cc..6da313417f 100644 --- a/packages/vchart-extension/src/charts/timeline/series/theme.ts +++ b/packages/vchart-extension/src/charts/timeline/series/theme.ts @@ -1,13 +1,17 @@ import type { IEventSeriesTheme } from './interface'; export const event: IEventSeriesTheme = { - dotLabelGap: 6, - titleSubTitleGap: 4, dot: { style: { size: 8 } }, + icon: { + visible: false, + style: { + size: 20 + } + }, line: { visible: true, style: { @@ -17,6 +21,8 @@ export const event: IEventSeriesTheme = { }, title: { visible: true, + offset: 6, + subTitleGap: 4, style: { fontSize: 14 } From 5244b0fd18fa4a9b13418cd766cad12aa3270803 Mon Sep 17 00:00:00 2001 From: xile611 Date: Mon, 2 Feb 2026 13:55:17 +0800 Subject: [PATCH 5/7] fix: fix tooltip of timeline --- .../charts/timeline/series/event-series.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/vchart-extension/src/charts/timeline/series/event-series.ts b/packages/vchart-extension/src/charts/timeline/series/event-series.ts index 1659e2f27a..893b98c186 100644 --- a/packages/vchart-extension/src/charts/timeline/series/event-series.ts +++ b/packages/vchart-extension/src/charts/timeline/series/event-series.ts @@ -80,7 +80,14 @@ export class EventSeries extends } getMeasureField(): string[] { - return []; + const fields: string[] = []; + if (this._eventField) { + fields.push(this._eventField); + } + if (this._subTitleField) { + fields.push(this._subTitleField); + } + return fields; } initMark(): void { @@ -106,6 +113,17 @@ export class EventSeries extends this._subTitleMark = this._createMark(EventSeries.mark.subTitle); } + protected initTooltip() { + super.initTooltip(); + + // 将 dot、icon 和 arrow mark 添加到 tooltip 触发器集合中 + this._dotMark && this._tooltipHelper.activeTriggerSet.mark.add(this._dotMark); + this._titleMark && this._tooltipHelper.activeTriggerSet.mark.add(this._titleMark); + this._subTitleMark && this._tooltipHelper.activeTriggerSet.mark.add(this._subTitleMark); + this._iconMark && this._tooltipHelper.activeTriggerSet.mark.add(this._iconMark); + this._arrowMark && this._tooltipHelper.activeTriggerSet.mark.add(this._arrowMark); + } + initMarkStyle(): void { if (this._axisLineMark) { this.setMarkStyle( From aa6c19783b5f3770c473fb736a90ec67b84b5fb5 Mon Sep 17 00:00:00 2001 From: xile611 Date: Mon, 2 Feb 2026 15:31:02 +0800 Subject: [PATCH 6/7] docs: update docs of timeline --- .../en/extension-chart/timeline-arrow.md | 146 +++++++++ .../en/extension-chart/timeline-basic.md | 141 +++++++++ .../en/extension-chart/timeline-group.md | 155 ++++++++++ .../en/extension-chart/timeline-vertical.md | 147 +++++++++ .../en/extension-chart/timeline-with-icon.md | 152 ++++++++++ docs/assets/examples/menu.json | 35 +++ .../zh/extension-chart/timeline-arrow.md | 146 +++++++++ .../zh/extension-chart/timeline-basic.md | 141 +++++++++ .../zh/extension-chart/timeline-group.md | 144 +++++++++ .../zh/extension-chart/timeline-vertical.md | 134 +++++++++ .../zh/extension-chart/timeline-with-icon.md | 146 +++++++++ .../Chart_Extensions/timeline.md | 279 ++++++++++++++++++ docs/assets/guide/menu.json | 7 + .../Chart_Extensions/timeline.md | 279 ++++++++++++++++++ .../vchart/preview/timeline-arrow_2.0.jpeg | Bin 0 -> 56470 bytes .../vchart/preview/timeline-basic_2.0.jpeg | Bin 0 -> 43709 bytes .../vchart/preview/timeline-group_2.0.jpeg | Bin 0 -> 48793 bytes .../vchart/preview/timeline-icon_2.0.jpeg | Bin 0 -> 40753 bytes .../vchart/preview/timeline-vertical_2.0.jpeg | Bin 0 -> 33476 bytes 19 files changed, 2052 insertions(+) create mode 100644 docs/assets/examples/en/extension-chart/timeline-arrow.md create mode 100644 docs/assets/examples/en/extension-chart/timeline-basic.md create mode 100644 docs/assets/examples/en/extension-chart/timeline-group.md create mode 100644 docs/assets/examples/en/extension-chart/timeline-vertical.md create mode 100644 docs/assets/examples/en/extension-chart/timeline-with-icon.md create mode 100644 docs/assets/examples/zh/extension-chart/timeline-arrow.md create mode 100644 docs/assets/examples/zh/extension-chart/timeline-basic.md create mode 100644 docs/assets/examples/zh/extension-chart/timeline-group.md create mode 100644 docs/assets/examples/zh/extension-chart/timeline-vertical.md create mode 100644 docs/assets/examples/zh/extension-chart/timeline-with-icon.md create mode 100644 docs/assets/guide/en/tutorial_docs/Chart_Extensions/timeline.md create mode 100644 docs/assets/guide/zh/tutorial_docs/Chart_Extensions/timeline.md create mode 100644 docs/public/vchart/preview/timeline-arrow_2.0.jpeg create mode 100644 docs/public/vchart/preview/timeline-basic_2.0.jpeg create mode 100644 docs/public/vchart/preview/timeline-group_2.0.jpeg create mode 100644 docs/public/vchart/preview/timeline-icon_2.0.jpeg create mode 100644 docs/public/vchart/preview/timeline-vertical_2.0.jpeg diff --git a/docs/assets/examples/en/extension-chart/timeline-arrow.md b/docs/assets/examples/en/extension-chart/timeline-arrow.md new file mode 100644 index 0000000000..51854e8797 --- /dev/null +++ b/docs/assets/examples/en/extension-chart/timeline-arrow.md @@ -0,0 +1,146 @@ +--- +category: examples +group: extension chart +title: Timeline Chart - With Arrows +keywords: extension, timeline, arrow +order: 5 +cover: /vchart/preview/timeline-arrow_2.0.jpeg +option: extensionChart +--- + +# Timeline Chart - With Arrows + +Timeline charts support displaying arrows between event nodes, providing a more intuitive representation of time flow and continuity between events. + +## Key Configurations + +- `arrow.visible: true` Enable arrow display +- `arrow.thickness` Set the thickness of arrows +- `arrow.style` Configure arrow style + +## Demo Code + +```javascript livedemo +/** --Please add the following code when using in business-- */ +// When using in business, please additionally depend on @visactor/vchart-extension, keeping the package version consistent with vchart +// import { registerTimelineChart } from '@visactor/vchart-extension'; +/** --Please add the above code when using in business-- */ + +/** --Please delete the following code when using in business-- */ +const { registerTimelineChart } = VChartExtension; +/** --Please delete the above code when using in business-- */ + +const spec = { + type: 'timeline', + direction: 'horizontal', + data: [ + { + id: 'timeline-data', + values: [ + { + id: '1', + title: 'Requirements', + detail: 'Collect and analyze user needs', + time: 1, + color: '#4A90E2' + }, + { + id: '2', + title: 'Design', + detail: 'Create technical solution', + time: 2, + color: '#50C8C8' + }, + { + id: '3', + title: 'Development', + detail: 'Implement features', + time: 3, + color: '#F5A623' + }, + { + id: '4', + title: 'Testing', + detail: 'Quality assurance', + time: 4, + color: '#9B59B6' + }, + { + id: '5', + title: 'Release', + detail: 'Go live', + time: 5, + color: '#2ECC71' + } + ] + } + ], + title: { + visible: true, + text: 'Project Development Process', + subtext: 'Complete workflow from requirements to release', + style: { + fontSize: 24, + fontWeight: 'bold', + fill: '#333' + }, + subtextStyle: { + fontSize: 14, + fill: '#666' + } + }, + series: [ + { + type: 'event', + dataId: 'timeline-data', + timeField: 'time', + eventField: 'title', + subTitleField: 'detail', + labelPosition: 'top-bottom', + dot: { + style: { + size: 12, + fill: datum => datum.color, + stroke: '#fff', + lineWidth: 2 + } + }, + arrow: { + visible: true, + thickness: 16, + style: { + fill: datum => datum.color, + fillOpacity: 0.3 + } + }, + title: { + style: { + fill: '#333', + fontSize: 14, + fontWeight: 'bold' + } + }, + subTitle: { + style: { + fill: '#666', + fontSize: 12 + } + }, + line: { + visible: false + } + } + ] +}; + +registerTimelineChart(); +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// Just for the convenience of console debugging, DO NOT COPY! +window['vchart'] = vchart; +``` + +## Related Tutorials + +[Extension Chart: Timeline Chart](/vchart/guide/tutorial_docs/Chart_Extensions/timeline) diff --git a/docs/assets/examples/en/extension-chart/timeline-basic.md b/docs/assets/examples/en/extension-chart/timeline-basic.md new file mode 100644 index 0000000000..d1ef061824 --- /dev/null +++ b/docs/assets/examples/en/extension-chart/timeline-basic.md @@ -0,0 +1,141 @@ +--- +category: examples +group: extension chart +title: Timeline Chart - Basic +keywords: extension, timeline +order: 1 +cover: /vchart/preview/timeline-basic_2.0.jpeg +option: extensionChart +--- + +# Timeline Chart - Basic + +Timeline charts are used to display events in chronological order, suitable for project milestones, corporate development history, product iterations, and other scenarios. + +## Key Configurations + +- `type: 'timeline'` Specifies the chart type as timeline chart +- `direction: 'horizontal' | 'vertical'` Specifies the direction of the timeline, horizontal or vertical +- `timeField` Specifies the time field +- `eventField` Specifies the event name field +- `subTitleField` Specifies the event detail field + +## Demo Code + +```javascript livedemo +/** --Please add the following code when using in business-- */ +// When using in business, please additionally depend on @visactor/vchart-extension, keeping the package version consistent with vchart +// import { registerTimelineChart } from '@visactor/vchart-extension'; +/** --Please add the above code when using in business-- */ + +/** --Please delete the following code when using in business-- */ +const { registerTimelineChart } = VChartExtension; +/** --Please delete the above code when using in business-- */ + +const spec = { + type: 'timeline', + direction: 'horizontal', + padding: { + left: 60, + right: 60, + top: 150, + bottom: 150 + }, + data: [ + { + id: 'timeline-data', + values: [ + { + id: '1', + year: '2021', + title: 'Product Launch', + detail: 'Released first generation product with market recognition', + time: 1, + color: '#4A90E2' + }, + { + id: '2', + year: '2022', + title: 'Tech Breakthrough', + detail: 'Achieved major breakthrough in core technology', + time: 2, + color: '#50C8C8' + }, + { + id: '3', + year: '2023', + title: 'Market Expansion', + detail: 'Business coverage extended to major cities nationwide', + time: 3, + color: '#F5A623' + }, + { + id: '4', + year: '2024', + title: 'Globalization', + detail: 'Entered international market, opening new chapter', + time: 4, + color: '#9B59B6' + } + ] + } + ], + title: { + visible: true, + text: 'Corporate Development History', + style: { + fontSize: 24, + fontWeight: 'bold', + fill: '#333' + } + }, + series: [ + { + type: 'event', + dataId: 'timeline-data', + timeField: 'time', + eventField: 'title', + subTitleField: 'detail', + labelPosition: 'top-bottom', + dot: { + style: { + size: 12, + fill: datum => datum.color, + stroke: '#fff', + lineWidth: 2 + } + }, + title: { + style: { + fill: '#333', + fontSize: 14, + fontWeight: 'bold' + } + }, + subTitle: { + style: { + fill: '#666', + fontSize: 12 + } + }, + line: { + style: { + stroke: '#c0c3c7', + lineWidth: 2 + } + } + } + ] +}; + +registerTimelineChart(); +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// Just for the convenience of console debugging, DO NOT COPY! +window['vchart'] = vchart; +``` + +## Related Tutorials + +[Extension Chart: Timeline Chart](/vchart/guide/tutorial_docs/Chart_Extensions/timeline) diff --git a/docs/assets/examples/en/extension-chart/timeline-group.md b/docs/assets/examples/en/extension-chart/timeline-group.md new file mode 100644 index 0000000000..f10ffee573 --- /dev/null +++ b/docs/assets/examples/en/extension-chart/timeline-group.md @@ -0,0 +1,155 @@ +--- +category: examples +group: extension chart +title: Timeline Chart - Grouped Display +keywords: extension, timeline, group +order: 4 +cover: /vchart/preview/timeline-group_2.0.jpeg +option: extensionChart +--- + +# Timeline Chart - Grouped Display + +By configuring seriesField, multiple timelines can be displayed in the same chart, suitable for comparing timelines of different themes or categories. + +## Key Configurations + +- `seriesField` Specifies the grouping field +- Multiple timelines are displayed in parallel, each timeline displays independently + +## Demo Code + +```javascript livedemo +/** --Please add the following code when using in business-- */ +// When using in business, please additionally depend on @visactor/vchart-extension, keeping the package version consistent with vchart +// import { registerTimelineChart } from '@visactor/vchart-extension'; +/** --Please add the above code when using in business-- */ + +/** --Please delete the following code when using in business-- */ +const { registerTimelineChart } = VChartExtension; +/** --Please delete the above code when using in business-- */ + +const spec = { + type: 'timeline', + direction: 'horizontal', + padding: { + left: 60, + right: 60, + top: 100, + bottom: 100 + }, + data: [ + { + id: 'timeline-data', + values: [ + { + category: 'Product Line A', + title: 'V1.0', + detail: 'Initial release', + time: 1, + color: '#4A90E2' + }, + { + category: 'Product Line A', + title: 'V2.0', + detail: 'Feature enhancement', + time: 3, + color: '#4A90E2' + }, + { + category: 'Product Line A', + title: 'V3.0', + detail: 'Performance optimization', + time: 5, + color: '#4A90E2' + }, + { + category: 'Product Line B', + title: 'Beta', + detail: 'Beta version', + time: 2, + color: '#50C8C8' + }, + { + category: 'Product Line B', + title: 'V1.0', + detail: 'Official release', + time: 4, + color: '#50C8C8' + }, + { + category: 'Product Line B', + title: 'V2.0', + detail: 'Major update', + time: 6, + color: '#50C8C8' + } + ] + } + ], + title: { + visible: true, + text: 'Multi-Product Line Comparison', + style: { + fontSize: 24, + fontWeight: 'bold', + fill: '#333' + } + }, + axes: [ + { + orient: 'bottom', + type: 'linear', + min: 0, + max: 7 + }, + { + orient: 'left', + type: 'band' + } + ], + timeField: 'time', + eventField: 'title', + subTitleField: 'detail', + seriesField: 'category', + labelPosition: 'top-bottom', + dot: { + style: { + size: 12, + fill: datum => datum.color, + stroke: '#fff', + lineWidth: 2 + } + }, + title: { + style: { + fill: '#333', + fontSize: 13, + fontWeight: 'bold' + } + }, + subTitle: { + style: { + fill: '#666', + fontSize: 11 + } + }, + line: { + style: { + stroke: datum => datum.color, + lineWidth: 2 + } + } +}; + +registerTimelineChart(); +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// Just for the convenience of console debugging, DO NOT COPY! +window['vchart'] = vchart; +``` + +## Related Tutorials + +[Extension Chart: Timeline Chart](/vchart/guide/tutorial_docs/Chart_Extensions/timeline) diff --git a/docs/assets/examples/en/extension-chart/timeline-vertical.md b/docs/assets/examples/en/extension-chart/timeline-vertical.md new file mode 100644 index 0000000000..081512126a --- /dev/null +++ b/docs/assets/examples/en/extension-chart/timeline-vertical.md @@ -0,0 +1,147 @@ +--- +category: examples +group: extension chart +title: Timeline Chart - Vertical Layout +keywords: extension, timeline, vertical +order: 3 +cover: /vchart/preview/timeline-vertical_2.0.jpeg +option: extensionChart +--- + +# Timeline Chart - Vertical Layout + +Timeline charts support vertical layout with time flowing from top to bottom, suitable when there is sufficient horizontal space on the page. + +## Key Configurations + +- `direction: 'vertical'` Specifies vertical layout +- `labelPosition: 'left-right' | 'right-left'` Controls alternating display of labels on left and right sides + +## Demo Code + +```javascript livedemo +/** --Please add the following code when using in business-- */ +// When using in business, please additionally depend on @visactor/vchart-extension, keeping the package version consistent with vchart +// import { registerTimelineChart } from '@visactor/vchart-extension'; +/** --Please add the above code when using in business-- */ + +/** --Please delete the following code when using in business-- */ +const { registerTimelineChart } = VChartExtension; +/** --Please delete the above code when using in business-- */ + +const spec = { + type: 'timeline', + direction: 'vertical', + padding: { + left: 200, + right: 200, + top: 60, + bottom: 60 + }, + data: [ + { + id: 'timeline-data', + values: [ + { + id: '1', + year: '2021 Q1', + title: 'V1.0 Release', + detail: 'First official version launched', + time: 1, + color: '#4A90E2' + }, + { + id: '2', + year: '2021 Q3', + title: 'V2.0 Upgrade', + detail: '50% performance improvement', + time: 2, + color: '#50C8C8' + }, + { + id: '3', + year: '2022 Q1', + title: 'V3.0 Refactor', + detail: 'Complete architecture upgrade', + time: 3, + color: '#F5A623' + }, + { + id: '4', + year: '2022 Q3', + title: 'V4.0 Internationalization', + detail: 'Multi-language support', + time: 4, + color: '#9B59B6' + }, + { + id: '5', + year: '2023 Q1', + title: 'V5.0 AI-Powered', + detail: 'AI capabilities introduced', + time: 5, + color: '#E74C3C' + } + ] + } + ], + title: { + visible: true, + text: 'Product Version Iteration History', + style: { + fontSize: 24, + fontWeight: 'bold', + fill: '#333' + } + }, + series: [ + { + type: 'event', + dataId: 'timeline-data', + timeField: 'time', + eventField: 'title', + subTitleField: 'detail', + labelPosition: 'left-right', + dotLabelGap: 10, + dot: { + style: { + size: 12, + fill: datum => datum.color, + stroke: '#fff', + lineWidth: 2 + } + }, + title: { + style: { + fill: '#333', + fontSize: 14, + fontWeight: 'bold' + } + }, + subTitle: { + style: { + fill: '#666', + fontSize: 12 + } + }, + line: { + style: { + stroke: '#c0c3c7', + lineWidth: 2 + } + } + } + ] +}; + +registerTimelineChart(); +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// Just for the convenience of console debugging, DO NOT COPY! +window['vchart'] = vchart; +``` + +## Related Tutorials + +[Extension Chart: Timeline Chart](/vchart/guide/tutorial_docs/Chart_Extensions/timeline) diff --git a/docs/assets/examples/en/extension-chart/timeline-with-icon.md b/docs/assets/examples/en/extension-chart/timeline-with-icon.md new file mode 100644 index 0000000000..9d5166a81c --- /dev/null +++ b/docs/assets/examples/en/extension-chart/timeline-with-icon.md @@ -0,0 +1,152 @@ +--- +category: examples +group: extension chart +title: Timeline Chart - With Icons +keywords: extension, timeline, icon +order: 2 +cover: /vchart/preview/timeline-icon_2.0.jpeg +option: extensionChart +--- + +# Timeline Chart - With Icons + +Timeline charts support adding icons to event nodes, making information display more intuitive and rich. Icons can be displayed symmetrically with titles relative to the timeline. + +## Key Configurations + +- `iconField` Specifies the icon field +- `icon.style` Configures icon style +- Icons are symmetric with titles relative to the timeline: when title is above, icon is below; when title is on left, icon is on right + +## Demo Code + +```javascript livedemo +/** --Please add the following code when using in business-- */ +// When using in business, please additionally depend on @visactor/vchart-extension, keeping the package version consistent with vchart +// import { registerTimelineChart } from '@visactor/vchart-extension'; +/** --Please add the above code when using in business-- */ + +/** --Please delete the following code when using in business-- */ +const { registerTimelineChart } = VChartExtension; +/** --Please delete the above code when using in business-- */ + +const spec = { + type: 'timeline', + direction: 'horizontal', + padding: { + left: 60, + right: 60, + top: 120, + bottom: 120 + }, + data: [ + { + id: 'timeline-data', + values: [ + { + id: '1', + year: '2021', + title: 'Product Launch', + detail: 'Released first generation product with market recognition', + icon: 'star', + time: 1, + color: '#4A90E2' + }, + { + id: '2', + year: '2022', + title: 'Tech Breakthrough', + detail: 'Achieved major breakthrough in core technology', + icon: 'triangleUp', + time: 2, + color: '#50C8C8' + }, + { + id: '3', + year: '2023', + title: 'Market Expansion', + detail: 'Business coverage extended to major cities nationwide', + icon: 'diamond', + time: 3, + color: '#F5A623' + }, + { + id: '4', + year: '2024', + title: 'Globalization', + detail: 'Entered international market, opening new chapter', + icon: 'cross', + time: 4, + color: '#9B59B6' + } + ] + } + ], + title: { + visible: true, + text: 'Corporate Development History - With Icons', + style: { + fontSize: 24, + fontWeight: 'bold', + fill: '#333' + } + }, + series: [ + { + type: 'event', + dataId: 'timeline-data', + timeField: 'time', + eventField: 'title', + subTitleField: 'detail', + iconField: 'icon', + labelPosition: 'top-bottom', + dot: { + style: { + size: 10, + fill: datum => datum.color, + stroke: '#fff', + lineWidth: 2 + } + }, + icon: { + visible: true, + style: { + size: 24, + fill: datum => datum.color + } + }, + title: { + style: { + fill: '#333', + fontSize: 14, + fontWeight: 'bold' + } + }, + subTitle: { + style: { + fill: '#666', + fontSize: 12, + lineHeight: 18 + } + }, + line: { + style: { + stroke: '#c0c3c7', + lineWidth: 2 + } + } + } + ] +}; + +registerTimelineChart(); +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// Just for the convenience of console debugging, DO NOT COPY! +window['vchart'] = vchart; +``` + +## Related Tutorials + +[Extension Chart: Timeline Chart](/vchart/guide/tutorial_docs/Chart_Extensions/timeline) diff --git a/docs/assets/examples/menu.json b/docs/assets/examples/menu.json index 3e719af8f5..3167ad7df5 100644 --- a/docs/assets/examples/menu.json +++ b/docs/assets/examples/menu.json @@ -2027,6 +2027,41 @@ "en": "extension-chart" }, "children": [ + { + "path": "timeline-basic", + "title": { + "zh": "基础时间轴图", + "en": "Basic Timeline Chart" + } + }, + { + "path": "timeline-with-icon", + "title": { + "zh": "带图标的时间轴图", + "en": "Timeline Chart with Icons" + } + }, + { + "path": "timeline-vertical", + "title": { + "zh": "垂直布局时间轴图", + "en": "Vertical Timeline Chart" + } + }, + { + "path": "timeline-group", + "title": { + "zh": "分组时间轴图", + "en": "Grouped Timeline Chart" + } + }, + { + "path": "timeline-arrow", + "title": { + "zh": "带箭头的时间轴图", + "en": "Timeline Chart with Arrows" + } + }, { "path": "sequence-scatter-link-classification", "title": { diff --git a/docs/assets/examples/zh/extension-chart/timeline-arrow.md b/docs/assets/examples/zh/extension-chart/timeline-arrow.md new file mode 100644 index 0000000000..30f8bba46a --- /dev/null +++ b/docs/assets/examples/zh/extension-chart/timeline-arrow.md @@ -0,0 +1,146 @@ +--- +category: examples +group: extension chart +title: 时间轴图-带箭头连接 +keywords: extension, timeline, arrow +order: 5 +cover: /vchart/preview/timeline-arrow_2.0.jpeg +option: extensionChart +--- + +# 时间轴图-带箭头连接 + +时间轴图支持在事件节点之间显示箭头,更直观地展示时间流向和事件之间的连贯性。 + +## 关键配置 + +- `arrow.visible: true` 启用箭头显示 +- `arrow.thickness` 设置箭头的粗细 +- `arrow.style` 配置箭头样式 + +## 代码演示 + +```javascript livedemo +/** --在业务中使用时请添加以下代码-- */ +// 在业务中使用时, 请额外依赖 @visactor/vchart-extension,包版本保持和vchart一致 +// import { registerTimelineChart } from '@visactor/vchart-extension'; +/** --在业务中使用时请添加以上代码-- */ + +/** --在业务中使用时请删除以下代码-- */ +const { registerTimelineChart } = VChartExtension; +/** --在业务中使用时请删除以上代码-- */ + +const spec = { + type: 'timeline', + direction: 'horizontal', + data: [ + { + id: 'timeline-data', + values: [ + { + id: '1', + title: '需求分析', + detail: '收集并分析用户需求', + time: 1, + color: '#4A90E2' + }, + { + id: '2', + title: '方案设计', + detail: '制定技术方案', + time: 2, + color: '#50C8C8' + }, + { + id: '3', + title: '开发实现', + detail: '编码开发功能', + time: 3, + color: '#F5A623' + }, + { + id: '4', + title: '测试验收', + detail: '质量保证与验收', + time: 4, + color: '#9B59B6' + }, + { + id: '5', + title: '上线发布', + detail: '正式发布上线', + time: 5, + color: '#2ECC71' + } + ] + } + ], + title: { + visible: true, + text: '项目开发流程', + subtext: '从需求到上线的完整流程', + style: { + fontSize: 24, + fontWeight: 'bold', + fill: '#333' + }, + subtextStyle: { + fontSize: 14, + fill: '#666' + } + }, + series: [ + { + type: 'event', + dataId: 'timeline-data', + timeField: 'time', + eventField: 'title', + subTitleField: 'detail', + labelPosition: 'top-bottom', + dot: { + style: { + size: 12, + fill: datum => datum.color, + stroke: '#fff', + lineWidth: 2 + } + }, + arrow: { + visible: true, + thickness: 16, + style: { + fill: datum => datum.color, + fillOpacity: 0.3 + } + }, + title: { + style: { + fill: '#333', + fontSize: 14, + fontWeight: 'bold' + } + }, + subTitle: { + style: { + fill: '#666', + fontSize: 12 + } + }, + line: { + visible: false + } + } + ] +}; + +registerTimelineChart(); +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// Just for the convenience of console debugging, DO NOT COPY! +window['vchart'] = vchart; +``` + +## 相关教程 + +[扩展图表:时间轴图](/vchart/guide/tutorial_docs/Chart_Extensions/timeline) diff --git a/docs/assets/examples/zh/extension-chart/timeline-basic.md b/docs/assets/examples/zh/extension-chart/timeline-basic.md new file mode 100644 index 0000000000..d223c4fa8c --- /dev/null +++ b/docs/assets/examples/zh/extension-chart/timeline-basic.md @@ -0,0 +1,141 @@ +--- +category: examples +group: extension chart +title: 时间轴图-基础 +keywords: extension, timeline +order: 1 +cover: /vchart/preview/timeline-basic_2.0.jpeg +option: extensionChart +--- + +# 时间轴图-基础 + +时间轴图用于展示事件按时间顺序发生的过程,适合用于项目里程碑、企业发展历程、产品迭代等场景。 + +## 关键配置 + +- `type: 'timeline'` 指定图表类型为时间轴图 +- `direction: 'horizontal' | 'vertical'` 指定时间轴的方向,水平或垂直 +- `timeField` 指定时间字段 +- `eventField` 指定事件名称字段 +- `subTitleField` 指定事件详情字段 + +## 代码演示 + +```javascript livedemo +/** --在业务中使用时请添加以下代码-- */ +// 在业务中使用时, 请额外依赖 @visactor/vchart-extension,包版本保持和vchart一致 +// import { registerTimelineChart } from '@visactor/vchart-extension'; +/** --在业务中使用时请添加以上代码-- */ + +/** --在业务中使用时请删除以下代码-- */ +const { registerTimelineChart } = VChartExtension; +/** --在业务中使用时请删除以上代码-- */ + +const spec = { + type: 'timeline', + direction: 'horizontal', + padding: { + left: 60, + right: 60, + top: 150, + bottom: 150 + }, + data: [ + { + id: 'timeline-data', + values: [ + { + id: '1', + year: '2021', + title: '产品发布', + detail: '发布第一代产品,获得市场认可', + time: 1, + color: '#4A90E2' + }, + { + id: '2', + year: '2022', + title: '技术突破', + detail: '核心技术获得重大突破', + time: 2, + color: '#50C8C8' + }, + { + id: '3', + year: '2023', + title: '市场扩展', + detail: '业务覆盖全国主要城市', + time: 3, + color: '#F5A623' + }, + { + id: '4', + year: '2024', + title: '国际化', + detail: '进军国际市场,开启新篇章', + time: 4, + color: '#9B59B6' + } + ] + } + ], + title: { + visible: true, + text: '企业发展历程', + style: { + fontSize: 24, + fontWeight: 'bold', + fill: '#333' + } + }, + series: [ + { + type: 'event', + dataId: 'timeline-data', + timeField: 'time', + eventField: 'title', + subTitleField: 'detail', + labelPosition: 'top-bottom', + dot: { + style: { + size: 12, + fill: datum => datum.color, + stroke: '#fff', + lineWidth: 2 + } + }, + title: { + style: { + fill: '#333', + fontSize: 14, + fontWeight: 'bold' + } + }, + subTitle: { + style: { + fill: '#666', + fontSize: 12 + } + }, + line: { + style: { + stroke: '#c0c3c7', + lineWidth: 2 + } + } + } + ] +}; + +registerTimelineChart(); +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// Just for the convenience of console debugging, DO NOT COPY! +window['vchart'] = vchart; +``` + +## 相关教程 + +[扩展图表:时间轴图](/vchart/guide/tutorial_docs/Chart_Extensions/timeline) diff --git a/docs/assets/examples/zh/extension-chart/timeline-group.md b/docs/assets/examples/zh/extension-chart/timeline-group.md new file mode 100644 index 0000000000..f848af0832 --- /dev/null +++ b/docs/assets/examples/zh/extension-chart/timeline-group.md @@ -0,0 +1,144 @@ +--- +category: examples +group: extension chart +title: 时间轴图-分组展示 +keywords: extension, timeline, group +order: 4 +cover: /vchart/preview/timeline-group_2.0.jpeg +option: extensionChart +--- + +# 时间轴图-分组展示 + +通过配置 seriesField,可以在同一个图表中展示多条时间轴,适合对比展示不同主题或类别的时间线。 + +## 关键配置 + +- `seriesField` 指定分组字段 +- 多条时间轴会并行显示,每条时间轴独立展示 + +## 代码演示 + +```javascript livedemo +/** --在业务中使用时请添加以下代码-- */ +// 在业务中使用时, 请额外依赖 @visactor/vchart-extension,包版本保持和vchart一致 +// import { registerTimelineChart } from '@visactor/vchart-extension'; +/** --在业务中使用时请添加以上代码-- */ + +/** --在业务中使用时请删除以下代码-- */ +const { registerTimelineChart } = VChartExtension; +/** --在业务中使用时请删除以上代码-- */ + +const spec = { + type: 'timeline', + direction: 'horizontal', + padding: { + left: 60, + right: 60, + top: 100, + bottom: 100 + }, + data: [ + { + id: 'timeline-data', + values: [ + { + category: '产品线A', + title: 'V1.0', + detail: '首次发布', + time: 1, + color: '#4A90E2' + }, + { + category: '产品线A', + title: 'V2.0', + detail: '功能增强', + time: 3, + color: '#4A90E2' + }, + { + category: '产品线A', + title: 'V3.0', + detail: '性能优化', + time: 5, + color: '#4A90E2' + }, + { + category: '产品线B', + title: 'Beta', + detail: '测试版本', + time: 2, + color: '#50C8C8' + }, + { + category: '产品线B', + title: 'V1.0', + detail: '正式发布', + time: 4, + color: '#50C8C8' + }, + { + category: '产品线B', + title: 'V2.0', + detail: '重大更新', + time: 6, + color: '#50C8C8' + } + ] + } + ], + title: { + visible: true, + text: '多产品线发展对比', + style: { + fontSize: 24, + fontWeight: 'bold', + fill: '#333' + } + }, + + timeField: 'time', + eventField: 'title', + subTitleField: 'detail', + seriesField: 'category', + labelPosition: 'top-bottom', + dot: { + style: { + size: 12, + fill: datum => datum.color, + stroke: '#fff', + lineWidth: 2 + } + }, + title: { + style: { + fill: '#333', + fontSize: 13, + fontWeight: 'bold' + } + }, + subTitle: { + style: { + fill: '#666', + fontSize: 11 + } + }, + line: { + style: { + stroke: datum => datum.color, + lineWidth: 2 + } + } +}; + +registerTimelineChart(); +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// Just for the convenience of console debugging, DO NOT COPY! +window['vchart'] = vchart; +``` + +## 相关教程 + +[扩展图表:时间轴图](/vchart/guide/tutorial_docs/Chart_Extensions/timeline) diff --git a/docs/assets/examples/zh/extension-chart/timeline-vertical.md b/docs/assets/examples/zh/extension-chart/timeline-vertical.md new file mode 100644 index 0000000000..6fa15521a2 --- /dev/null +++ b/docs/assets/examples/zh/extension-chart/timeline-vertical.md @@ -0,0 +1,134 @@ +--- +category: examples +group: extension chart +title: 时间轴图-垂直布局 +keywords: extension, timeline, vertical +order: 3 +cover: /vchart/preview/timeline-vertical_2.0.jpeg +option: extensionChart +--- + +# 时间轴图-垂直布局 + +时间轴图支持垂直布局,时间从上到下展开,适合在页面左右空间充足时使用。 + +## 关键配置 + +- `direction: 'vertical'` 指定为垂直布局 +- `labelPosition: 'left-right' | 'right-left'` 控制标签在左右侧的交替显示 + +## 代码演示 + +```javascript livedemo +/** --在业务中使用时请添加以下代码-- */ +// 在业务中使用时, 请额外依赖 @visactor/vchart-extension,包版本保持和vchart一致 +// import { registerTimelineChart } from '@visactor/vchart-extension'; +/** --在业务中使用时请添加以上代码-- */ + +/** --在业务中使用时请删除以下代码-- */ +const { registerTimelineChart } = VChartExtension; +/** --在业务中使用时请删除以上代码-- */ + +const spec = { + type: 'timeline', + direction: 'vertical', + data: [ + { + id: 'timeline-data', + values: [ + { + id: '1', + year: '2021 Q1', + title: 'V1.0 发布', + detail: '首个正式版本上线', + time: 1, + color: '#4A90E2' + }, + { + id: '2', + year: '2021 Q3', + title: 'V2.0 升级', + detail: '性能优化50%', + time: 2, + color: '#50C8C8' + }, + { + id: '3', + year: '2022 Q1', + title: 'V3.0 重构', + detail: '架构全面升级', + time: 3, + color: '#F5A623' + }, + { + id: '4', + year: '2022 Q3', + title: 'V4.0 国际化', + detail: '支持多语言', + time: 4, + color: '#9B59B6' + }, + { + id: '5', + year: '2023 Q1', + title: 'V5.0 智能化', + detail: '引入AI能力', + time: 5, + color: '#E74C3C' + } + ] + } + ], + title: { + visible: true, + text: '产品版本迭代历程', + style: { + fontSize: 24, + fontWeight: 'bold', + fill: '#333' + } + }, + timeField: 'time', + eventField: 'title', + subTitleField: 'detail', + labelPosition: 'left-right', + dot: { + style: { + size: 12, + fill: datum => datum.color, + stroke: '#fff', + lineWidth: 2 + } + }, + title: { + style: { + fill: '#333', + fontSize: 14, + fontWeight: 'bold' + } + }, + subTitle: { + style: { + fill: '#666', + fontSize: 12 + } + }, + line: { + style: { + stroke: '#c0c3c7', + lineWidth: 2 + } + } +}; + +registerTimelineChart(); +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// Just for the convenience of console debugging, DO NOT COPY! +window['vchart'] = vchart; +``` + +## 相关教程 + +[扩展图表:时间轴图](/vchart/guide/tutorial_docs/Chart_Extensions/timeline) diff --git a/docs/assets/examples/zh/extension-chart/timeline-with-icon.md b/docs/assets/examples/zh/extension-chart/timeline-with-icon.md new file mode 100644 index 0000000000..fa83d31b2a --- /dev/null +++ b/docs/assets/examples/zh/extension-chart/timeline-with-icon.md @@ -0,0 +1,146 @@ +--- +category: examples +group: extension chart +title: 时间轴图-带图标 +keywords: extension, timeline, icon +order: 2 +cover: /vchart/preview/timeline-icon_2.0.jpeg +option: extensionChart +--- + +# 时间轴图-带图标 + +时间轴图支持在事件节点上添加图标,使信息展示更加直观和丰富。图标可以与标题关于时间轴对称显示。 + +## 关键配置 + +- `iconField` 指定图标字段 +- `icon.style` 配置图标样式 +- 图标与标题关于时间轴对称:当标题在上方时,图标在下方;当标题在左侧时,图标在右侧 + +## 代码演示 + +```javascript livedemo +/** --在业务中使用时请添加以下代码-- */ +// 在业务中使用时, 请额外依赖 @visactor/vchart-extension,包版本保持和vchart一致 +// import { registerTimelineChart } from '@visactor/vchart-extension'; +/** --在业务中使用时请添加以上代码-- */ + +/** --在业务中使用时请删除以下代码-- */ +const { registerTimelineChart } = VChartExtension; +/** --在业务中使用时请删除以上代码-- */ + +const spec = { + type: 'timeline', + direction: 'horizontal', + data: [ + { + id: 'timeline-data', + values: [ + { + id: '1', + year: '2021', + title: '产品发布', + detail: '发布第一代产品,获得市场认可', + icon: 'star', + time: 1, + color: '#4A90E2' + }, + { + id: '2', + year: '2022', + title: '技术突破', + detail: '核心技术获得重大突破', + icon: 'triangleUp', + time: 2, + color: '#50C8C8' + }, + { + id: '3', + year: '2023', + title: '市场扩展', + detail: '业务覆盖全国主要城市', + icon: 'diamond', + time: 3, + color: '#F5A623' + }, + { + id: '4', + year: '2024', + title: '国际化', + detail: '进军国际市场,开启新篇章', + icon: 'cross', + time: 4, + color: '#9B59B6' + } + ] + } + ], + title: { + visible: true, + text: '企业发展历程 - 带图标', + style: { + fontSize: 24, + fontWeight: 'bold', + fill: '#333' + } + }, + series: [ + { + type: 'event', + dataId: 'timeline-data', + timeField: 'time', + eventField: 'title', + subTitleField: 'detail', + iconField: 'icon', + labelPosition: 'top-bottom', + dot: { + style: { + size: 10, + fill: datum => datum.color, + stroke: '#fff', + lineWidth: 2 + } + }, + icon: { + visible: true, + style: { + size: 24, + fill: datum => datum.color + } + }, + title: { + style: { + fill: '#333', + fontSize: 14, + fontWeight: 'bold' + } + }, + subTitle: { + style: { + fill: '#666', + fontSize: 12, + lineHeight: 18 + } + }, + line: { + style: { + stroke: '#c0c3c7', + lineWidth: 2 + } + } + } + ] +}; + +registerTimelineChart(); +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// Just for the convenience of console debugging, DO NOT COPY! +window['vchart'] = vchart; +``` + +## 相关教程 + +[扩展图表:时间轴图](/vchart/guide/tutorial_docs/Chart_Extensions/timeline) diff --git a/docs/assets/guide/en/tutorial_docs/Chart_Extensions/timeline.md b/docs/assets/guide/en/tutorial_docs/Chart_Extensions/timeline.md new file mode 100644 index 0000000000..4c64beec6d --- /dev/null +++ b/docs/assets/guide/en/tutorial_docs/Chart_Extensions/timeline.md @@ -0,0 +1,279 @@ +# Extension Chart: Timeline Chart + +Timeline Chart is a visualization chart used to display events in chronological order, particularly suitable for showing project progress, corporate development history, product iteration processes, and similar scenarios. + +VChart provides a timeline chart extension component that supports both horizontal and vertical layout modes, with flexible configuration of event node styles, label positions, icons, and other elements. + +![img](https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/vchart/preview/timeline/timeline-basic.png) + +## How to Use Extension Charts + +Timeline charts need to be manually registered before use. The registration and usage methods are as follows: + +```js +import VChart from '@visactor/vchart'; +import { registerTimelineChart } from '@visactor/vchart-extension'; + +const spec = { + type: 'timeline' + // your spec +}; +registerTimelineChart(); + +const vchart = new VChart(spec, { dom: 'chart' }); +vchart.renderSync(); +``` + +If using CDN import, the registration method is as follows: + +```html + + + +``` + +## Related Configuration Items + +### Timeline Chart Configuration + +```ts +export interface ITimelineChartSpec extends ICartesianChartSpec { + type: 'timeline'; + /** + * Timeline direction + * - 'horizontal': Horizontal direction, time flows from left to right + * - 'vertical': Vertical direction, time flows from top to bottom + */ + direction?: 'horizontal' | 'vertical'; + /** + * Series configuration + */ + series?: IEventSeriesSpec[]; +} +``` + +### Event Series Configuration + +```ts +export interface IEventSeriesSpec extends ICartesianSeriesSpec { + type: 'event'; + /** + * Time field, used to specify the position of events on the timeline + */ + timeField?: string; + /** + * Event name field + */ + eventField?: string; + /** + * Event detail field (subtitle) + */ + subTitleField?: string; + /** + * Icon field, used to display icons or images + */ + iconField?: string; + /** + * Series field, used for grouped display + */ + seriesField?: string; + /** + * Position of title and subtitle + * - Horizontal layout: 'top' | 'bottom' | 'top-bottom' | 'bottom-top' + * - Vertical layout: 'left' | 'right' | 'left-right' | 'right-left' + */ + labelPosition?: LabelPosition; + /** + * Icon mark configuration + * offset: Icon offset distance relative to the dot, in pixels. Positive values offset outward, negative values offset inward + */ + icon?: IMarkSpec & { offset?: number }; + /** + * Event title mark configuration + * subTitleGap: Gap between title and subtitle, in pixels + * offset: Title offset distance relative to the dot, in pixels. Positive values offset outward, negative values offset inward + */ + title?: IMarkSpec & { subTitleGap?: number; offset?: number }; + /** + * Event subtitle mark configuration + */ + subTitle?: IMarkSpec; + /** + * Event line mark configuration + */ + line?: IMarkSpec; + /** + * Arrow mark configuration + * thickness: Arrow thickness, in pixels + */ + arrow?: IMarkSpec & { thickness?: number }; +} +``` + +## Timeline Chart Examples + +- [Basic Timeline Chart](/vchart/demo/extension-chart/timeline-basic) +- [Timeline Chart with Icons](/vchart/demo/extension-chart/timeline-with-icon) + +## Configuration Details + +### Layout Direction + +Timeline charts support two layout directions: + +- **Horizontal Layout** (`direction: 'horizontal'`): Time flows from left to right, suitable for displaying horizontal timeline processes +- **Vertical Layout** (`direction: 'vertical'`): Time flows from top to bottom, suitable for displaying vertical development histories + +### Data Field Configuration + +Timeline charts require the following data field configurations: + +- `timeField`: Time field, used to determine the position of events on the timeline +- `eventField`: Event name field, displayed as the title +- `subTitleField`: Event detail field, displayed as subtitle (optional) +- `iconField`: Icon field, used to display icons or images (optional) +- `seriesField`: Series field, used for grouped display of multiple timelines (optional) + +### Label Position Configuration + +The `labelPosition` controls the display position of titles and subtitles: + +**For Horizontal Layout:** + +- `top`: Title always above the timeline +- `bottom`: Title always below the timeline +- `top-bottom`: Titles alternate between top and bottom (first on top, second on bottom) +- `bottom-top`: Titles alternate between bottom and top (first on bottom, second on top) + +**For Vertical Layout:** + +- `left`: Title always on the left of the timeline +- `right`: Title always on the right of the timeline +- `left-right`: Titles alternate between left and right (first on left, second on right) +- `right-left`: Titles alternate between right and left (first on right, second on left) + +### Icon Feature + +Timeline charts support adding icons to event nodes. Icons are displayed symmetrically with titles relative to the timeline: + +- Horizontal layout: When title is above, icon is below; when title is below, icon is above +- Vertical layout: When title is on the left, icon is on the right; when title is on the right, icon is on the left + +Icons can use VChart's built-in symbol shapes (such as 'star', 'diamond', 'triangleUp', etc.) or image URLs. + +### Style Configuration + +#### Dot Mark Style + +Configure dot mark style for event nodes via `dot`: + +```js +dot: { + style: { + size: 12, + fill: '#4A90E2', + stroke: '#fff', + lineWidth: 2 + } +} +``` + +#### Icon Style + +Configure icon style via `icon`: + +```js +icon: { + style: { + size: 24, + fill: '#4A90E2', + shape: 'star' // or image URL + } +} +``` + +#### Title Style + +Configure title and subtitle styles separately via `title` and `subTitle`: + +```js +title: { + style: { + fontSize: 14, + fontWeight: 'bold', + fill: '#333' + } +}, +subTitle: { + style: { + fontSize: 12, + fill: '#666', + lineHeight: 18 + } +} +``` + +#### Timeline Line Style + +Configure timeline line style via `line`: + +```js +line: { + style: { + stroke: '#c0c3c7', + lineWidth: 2 + } +} +``` + +#### Arrow Style + +Configure connecting arrow style via `arrow`: + +```js +arrow: { + visible: true, + thickness: 16, + style: { + fill: '#4A90E2' + } +} +``` + +### Grouped Display + +By configuring `seriesField`, you can implement grouped display of multiple timelines, suitable for comparing timelines of multiple themes or categories. + +### Interactive Features + +Timeline charts support the following interactive features: + +- **Tooltip**: When hovering over dot marks, icons, or arrows, detailed event information is displayed +- **Click Events**: You can listen to click events on dot marks to implement custom interactions + +## Application Scenarios + +Timeline charts are suitable for the following scenarios: + +1. **Project Management**: Display project milestones and key nodes +2. **Corporate Development**: Show company development history and important events +3. **Product Iteration**: Display product version update history +4. **Historical Events**: Show chronological order of historical events +5. **Resume**: Display work experience and educational background + +## Notes + +1. Time fields should be numeric or date types to ensure correct sorting +2. When using `seriesField` for grouping, reasonably control the number of groups to avoid overcrowded charts +3. Label position selection should consider content length and chart space to avoid text overlap +4. Icon size should coordinate with the overall layout, not too large or too small diff --git a/docs/assets/guide/menu.json b/docs/assets/guide/menu.json index 1a0727f952..3fe4488545 100644 --- a/docs/assets/guide/menu.json +++ b/docs/assets/guide/menu.json @@ -954,6 +954,13 @@ "zh": "蜡烛图", "en": "Candlestick" } + }, + { + "path": "timeline", + "title": { + "zh": "时间线图组件", + "en": "Timeline" + } } ] } diff --git a/docs/assets/guide/zh/tutorial_docs/Chart_Extensions/timeline.md b/docs/assets/guide/zh/tutorial_docs/Chart_Extensions/timeline.md new file mode 100644 index 0000000000..6cee61874b --- /dev/null +++ b/docs/assets/guide/zh/tutorial_docs/Chart_Extensions/timeline.md @@ -0,0 +1,279 @@ +# 扩展图表:时间轴图 + +时间轴图(Timeline Chart)是一种用于按时间顺序展示事件的可视化图表,特别适合展示项目进度、企业发展历程、产品迭代过程等场景。 + +VChart 提供了时间轴图扩展组件,支持水平和垂直两种布局方式,可以灵活配置事件节点的样式、标签位置和图标等元素。 + +![img](https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/vchart/preview/timeline/timeline-basic.png) + +## 如何使用扩展图表 + +时间轴图需要手动注册后才能使用,注册和使用方式如下: + +```js +import VChart from '@visactor/vchart'; +import { registerTimelineChart } from '@visactor/vchart-extension'; + +const spec = { + type: 'timeline' + // your spec +}; +registerTimelineChart(); + +const vchart = new VChart(spec, { dom: 'chart' }); +vchart.renderSync(); +``` + +如果是通过 cdn 引入的方式,注册方式如下: + +```html + + + +``` + +## 相关配置项 + +### 时间轴图配置 + +```ts +export interface ITimelineChartSpec extends ICartesianChartSpec { + type: 'timeline'; + /** + * 时间轴方向 + * - 'horizontal': 水平方向,时间从左到右 + * - 'vertical': 垂直方向,时间从上到下 + */ + direction?: 'horizontal' | 'vertical'; + /** + * 系列配置 + */ + series?: IEventSeriesSpec[]; +} +``` + +### 事件系列配置 + +```ts +export interface IEventSeriesSpec extends ICartesianSeriesSpec { + type: 'event'; + /** + * 时间字段,用于指定事件在时间轴上的位置 + */ + timeField?: string; + /** + * 事件名称字段 + */ + eventField?: string; + /** + * 事件详情字段(副标题) + */ + subTitleField?: string; + /** + * 图标字段,用于显示图标或图片 + */ + iconField?: string; + /** + * 系列字段,用于分组显示 + */ + seriesField?: string; + /** + * 标题和副标题的位置 + * - 水平布局: 'top' | 'bottom' | 'top-bottom' | 'bottom-top' + * - 垂直布局: 'left' | 'right' | 'left-right' | 'right-left' + */ + labelPosition?: LabelPosition; + /** + * 图标图元配置 + * offset: 图标相对于点的偏移距离,单位像素,正值向外偏移,负值向内偏移 + */ + icon?: IMarkSpec & { offset?: number }; + /** + * 事件标题图元配置 + * subTitleGap: 标题与副标题的间距,单位像素 + * offset: 标题相对于点的偏移距离,单位像素,正值向外偏移,负值向内偏移 + */ + title?: IMarkSpec & { subTitleGap?: number; offset?: number }; + /** + * 事件副标题图元配置 + */ + subTitle?: IMarkSpec; + /** + * 事件线图元配置 + */ + line?: IMarkSpec; + /** + * 箭头图元配置 + * thickness: 箭头的厚度,单位像素 + */ + arrow?: IMarkSpec & { thickness?: number }; +} +``` + +## 时间轴图示例 + +- [基础时间轴图](/vchart/demo/extension-chart/timeline-basic) +- [带图标的时间轴图](/vchart/demo/extension-chart/timeline-with-icon) + +## 配置详解 + +### 布局方向 + +时间轴图支持两种布局方向: + +- **水平布局** (`direction: 'horizontal'`): 时间从左到右展开,适合展示横向的时间进程 +- **垂直布局** (`direction: 'vertical'`): 时间从上到下展开,适合展示纵向的发展历程 + +### 数据字段配置 + +时间轴图需要配置以下数据字段: + +- `timeField`: 时间字段,用于确定事件在时间轴上的位置 +- `eventField`: 事件名称字段,显示为标题 +- `subTitleField`: 事件详情字段,显示为副标题(可选) +- `iconField`: 图标字段,用于显示图标或图片(可选) +- `seriesField`: 系列字段,用于分组展示多条时间轴(可选) + +### 标签位置配置 + +通过 `labelPosition` 可以控制标题和副标题的显示位置: + +**水平布局时:** + +- `top`: 标题始终在时间轴上方 +- `bottom`: 标题始终在时间轴下方 +- `top-bottom`: 标题交替显示在上方和下方(第一个在上,第二个在下) +- `bottom-top`: 标题交替显示在下方和上方(第一个在下,第二个在上) + +**垂直布局时:** + +- `left`: 标题始终在时间轴左侧 +- `right`: 标题始终在时间轴右侧 +- `left-right`: 标题交替显示在左侧和右侧(第一个在左,第二个在右) +- `right-left`: 标题交替显示在右侧和左侧(第一个在右,第二个在左) + +### 图标功能 + +时间轴图支持在事件节点上添加图标,图标会与标题关于时间轴对称显示: + +- 水平布局:当标题在上方时,图标在下方;当标题在下方时,图标在上方 +- 垂直布局:当标题在左侧时,图标在右侧;当标题在右侧时,图标在左侧 + +图标可以使用 VChart 内置的 symbol 形状(如 'star'、'diamond'、'triangleUp' 等)或图片 URL。 + +### 样式配置 + +#### 点标记样式 + +通过 `dot` 配置事件节点的点标记样式: + +```js +dot: { + style: { + size: 12, + fill: '#4A90E2', + stroke: '#fff', + lineWidth: 2 + } +} +``` + +#### 图标样式 + +通过 `icon` 配置图标的样式: + +```js +icon: { + style: { + size: 24, + fill: '#4A90E2', + shape: 'star' // 或图片 URL + } +} +``` + +#### 标题样式 + +通过 `title` 和 `subTitle` 分别配置标题和副标题的样式: + +```js +title: { + style: { + fontSize: 14, + fontWeight: 'bold', + fill: '#333' + } +}, +subTitle: { + style: { + fontSize: 12, + fill: '#666', + lineHeight: 18 + } +} +``` + +#### 时间轴线样式 + +通过 `line` 配置时间轴线的样式: + +```js +line: { + style: { + stroke: '#c0c3c7', + lineWidth: 2 + } +} +``` + +#### 箭头样式 + +通过 `arrow` 配置连接箭头的样式: + +```js +arrow: { + visible: true, + thickness: 16, + style: { + fill: '#4A90E2' + } +} +``` + +### 分组展示 + +通过配置 `seriesField`,可以实现多条时间轴的分组展示,适合对比展示多个主题或类别的时间线。 + +### 交互功能 + +时间轴图支持以下交互功能: + +- **Tooltip**: 鼠标悬停在点标记、图标或箭头上时,会显示事件的详细信息 +- **点击事件**: 可以监听点标记的点击事件,实现自定义交互 + +## 应用场景 + +时间轴图适用于以下场景: + +1. **项目管理**: 展示项目的里程碑和关键节点 +2. **企业发展**: 展示公司的发展历程和重要事件 +3. **产品迭代**: 展示产品版本的更新历史 +4. **历史事件**: 展示历史事件的时间顺序 +5. **个人简历**: 展示工作经历和教育背景 + +## 注意事项 + +1. 时间字段应该是数值或日期类型,以确保正确的排序 +2. 当使用 `seriesField` 分组时,建议合理控制分组数量,避免图表过于拥挤 +3. 标签位置的选择应该考虑内容长度和图表空间,避免文本重叠 +4. 图标大小应该与整体布局协调,不宜过大或过小 diff --git a/docs/public/vchart/preview/timeline-arrow_2.0.jpeg b/docs/public/vchart/preview/timeline-arrow_2.0.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..ff56e5c2253bb45625e4de8ce3059a048d1f7048 GIT binary patch literal 56470 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±u*t6le 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 O-GTmJ9f5`B}T1u1_0i{72K}jho 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 M1^yXKT>d%mUre+05dZ)H literal 0 HcmV?d00001 diff --git a/docs/public/vchart/preview/timeline-vertical_2.0.jpeg b/docs/public/vchart/preview/timeline-vertical_2.0.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..92b237a4162c9a8e00902997edfab90912332f18 GIT binary patch literal 33476 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 literal 0 HcmV?d00001 From e1b4f98733e8c7b1efbda22ba2f3712cae7d470a Mon Sep 17 00:00:00 2001 From: xile611 Date: Mon, 2 Feb 2026 15:31:24 +0800 Subject: [PATCH 7/7] fix: fix default axes of timeline chart --- .../charts/timeline/series/event-series.ts | 8 +- .../src/charts/timeline/series/interface.ts | 40 +++- .../charts/timeline/timeline-transformer.ts | 179 ++++++++---------- 3 files changed, 125 insertions(+), 102 deletions(-) diff --git a/packages/vchart-extension/src/charts/timeline/series/event-series.ts b/packages/vchart-extension/src/charts/timeline/series/event-series.ts index 893b98c186..a2034a30f0 100644 --- a/packages/vchart-extension/src/charts/timeline/series/event-series.ts +++ b/packages/vchart-extension/src/charts/timeline/series/event-series.ts @@ -95,19 +95,17 @@ export class EventSeries extends isSeriesMark: true, groupKey: this._seriesField }); - - this._dotMark = this._createMark(EventSeries.mark.dot, { + this._arrowMark = this._createMark((EventSeries.mark as any).arrow, { isSeriesMark: true }); - this._iconMark = this._createMark((EventSeries.mark as any).icon, { + this._dotMark = this._createMark(EventSeries.mark.dot, { isSeriesMark: true }); - this._arrowMark = this._createMark((EventSeries.mark as any).arrow, { + this._iconMark = this._createMark((EventSeries.mark as any).icon, { isSeriesMark: true }); - this._titleMark = this._createMark(EventSeries.mark.title); this._subTitleMark = this._createMark(EventSeries.mark.subTitle); diff --git a/packages/vchart-extension/src/charts/timeline/series/interface.ts b/packages/vchart-extension/src/charts/timeline/series/interface.ts index 4a89b80eab..e7e94671af 100644 --- a/packages/vchart-extension/src/charts/timeline/series/interface.ts +++ b/packages/vchart-extension/src/charts/timeline/series/interface.ts @@ -18,21 +18,57 @@ export type LabelPosition = HorizontalLabelPosition | VerticalLabelPosition; export interface IEventSeriesSpec extends ICartesianSeriesSpec, IEventSeriesTheme { type: 'event'; + /** + * 时间字段,用于指定事件在时间轴上的位置 + */ timeField?: string; + /** + * 事件名称字段 + */ eventField?: string; + /** + * 事件详情字段(副标题) + */ subTitleField?: string; + /** + * 系列字段,用于分组显示 + */ seriesField?: string; - /** icon 字段名,用于显示图标或图片 */ + /** + * 图标字段,用于显示图标或图片 + */ iconField?: string; /** 标题和副标题的位置 */ labelPosition?: LabelPosition; } export interface IEventSeriesTheme { + /** + * 点图元配置 + */ dot?: IMarkSpec; + /** + * 图标图元配置 + * offset: 图标相对于点的偏移距离,单位像素,正值向外偏移,负值向内偏移 + */ icon?: IMarkSpec & { offset?: number }; + /** + * 事件标题图元配置 + * subTitleGap: 标题与副标题的间距,单位像素 + * offset: 标题相对于点的偏移距离,单位像素,正值向外偏移,负值向内偏移 + */ title?: IMarkSpec & { subTitleGap?: number; offset?: number }; - subTitle?: IMarkSpec & { offset?: number }; + /** + * 事件副标题图元配置 + */ + subTitle?: IMarkSpec; + /** + * 事件线图元配置 + */ line?: IMarkSpec; + /** + * 箭头图元配置 + * thickness: 箭头的厚度,单位像素 + */ arrow?: IMarkSpec & { thickness?: number }; } diff --git a/packages/vchart-extension/src/charts/timeline/timeline-transformer.ts b/packages/vchart-extension/src/charts/timeline/timeline-transformer.ts index f13db7d8d7..bd24693b2c 100644 --- a/packages/vchart-extension/src/charts/timeline/timeline-transformer.ts +++ b/packages/vchart-extension/src/charts/timeline/timeline-transformer.ts @@ -1,6 +1,7 @@ -import { BaseChartSpecTransformer, merge } from '@visactor/vchart'; +import { BaseChartSpecTransformer } from '@visactor/vchart'; import type { ICartesianAxisSpec, ICartesianLinearAxisSpec } from '@visactor/vchart'; import type { ITimelineChartSpec } from './interface'; +import { isNil, get, merge, isObject } from '@visactor/vutils'; export class TimelineChartSpecTransformer< T extends ITimelineChartSpec = ITimelineChartSpec @@ -10,122 +11,110 @@ export class TimelineChartSpecTransformer< 'timeField', 'eventField', 'seriesField', - 'dotTypeField', 'titleField', 'subTitleField', + 'iconField', 'dot', 'title', 'subTitle', - 'symbol' + 'icon', + 'line', + 'arrow', + 'labelPosition', + 'sortDataByAxis' ]); } - transformSpec(spec: T): void { - super.transformSpec(spec); - this.transformSeriesSpec(spec); + protected _setDefaultXAxisSpec(spec: T): ICartesianAxisSpec { + return { + type: 'band', + orient: 'bottom', + label: { + visible: false + }, + tick: { + visible: false + }, + grid: { + visible: false + }, + domainLine: { + visible: false + }, + paddingInner: 0, + paddingOuter: 0 + } as ICartesianAxisSpec; + } - // 确定 direction(通过轴方向推断) - const rawAxis = spec.axes?.[0]; - const axisOrient = rawAxis?.orient; + protected _setDefaultYAxisSpec(spec: T): ICartesianAxisSpec { + return { + type: 'band', + inverse: true, + orient: 'left', + label: { + visible: false + }, + tick: { + visible: false + }, + grid: { + visible: false + }, + domainLine: { + visible: false + }, + paddingInner: 0, + paddingOuter: 0 + } as ICartesianAxisSpec; + } - // 默认为 horizontal - let direction: 'horizontal' | 'vertical' = 'horizontal'; - if (axisOrient === 'left' || axisOrient === 'right') { - direction = 'vertical'; - } else if (axisOrient === 'bottom' || axisOrient === 'top') { - direction = 'horizontal'; + protected _transformAxisSpec(spec: T) { + if (!spec.axes) { + spec.axes = []; } - // 确定默认的轴方向和类型 - const defaultOrient = direction === 'vertical' ? 'left' : 'bottom'; - const allowedOrients = direction === 'vertical' ? ['left', 'right'] : ['bottom', 'top']; - const orientNormalized: ICartesianAxisSpec['orient'] = allowedOrients.includes(axisOrient ?? '') - ? (axisOrient as ICartesianAxisSpec['orient']) - : (defaultOrient as ICartesianAxisSpec['orient']); - - // 确定轴类型,默认为 band - const axisType = rawAxis?.type ?? 'band'; - const typeNormalized: ICartesianAxisSpec['type'] = - axisType === 'linear' || axisType === 'time' || axisType === 'band' ? (axisType as any) : 'band'; + const haxAxes = { x: false, y: false }; + spec.axes.forEach((axis: ICartesianAxisSpec) => { + const { orient } = axis; + let defaultSpec: Partial = null; + if (orient === 'top' || orient === 'bottom') { + haxAxes.x = true; - // 构建轴配置 - const baseAxis: ICartesianAxisSpec = merge( - { - label: { - visible: false - }, - tick: { - visible: false - }, - grid: { - visible: false - }, - domainLine: { - visible: false - } - }, - orientNormalized === 'left' || orientNormalized === 'right' - ? { - inverse: true - } - : {}, - typeNormalized === 'band' ? { paddingInner: 0, paddingOuter: 0 } : {}, - { - ...((rawAxis ?? {}) as ICartesianAxisSpec), - orient: orientNormalized, - type: typeNormalized + defaultSpec = this._setDefaultXAxisSpec(spec); } - ); + if (orient === 'left' || orient === 'right') { + haxAxes.y = true; - // 检查是否有 seriesField,如果有则需要创建第二个分类轴 - const hasSeriesField = spec.series?.some(s => s.seriesField); + defaultSpec = this._setDefaultYAxisSpec(spec); + } - if (baseAxis.type === 'linear') { - const linearAxis: ICartesianLinearAxisSpec = { - ...(baseAxis as ICartesianLinearAxisSpec), - zero: (rawAxis as ICartesianLinearAxisSpec)?.zero ?? false - }; - spec.axes = [linearAxis]; - } else { - spec.axes = [baseAxis]; + if (defaultSpec) { + Object.keys(defaultSpec).forEach(key => { + (axis as any)[key] = isObject((axis as any)[key]) + ? merge((defaultSpec as any)[key], (axis as any)[key]) + : isNil((axis as any)[key]) + ? (defaultSpec as any)[key] + : (axis as any)[key]; + }); + } + }); + if (!haxAxes.x) { + spec.axes.push(this._setDefaultXAxisSpec(spec)); + } + if (!haxAxes.y) { + spec.axes.push(this._setDefaultYAxisSpec(spec)); } + } - // 如果有 seriesField,需要创建第二个分类轴 - if (hasSeriesField) { - const categoryAxisOrient = direction === 'vertical' ? 'bottom' : 'left'; - const categoryAxis: ICartesianAxisSpec = { - orient: categoryAxisOrient, - type: 'band', - label: { - visible: false - }, - tick: { - visible: false - }, - grid: { - visible: false - }, - domainLine: { - visible: false - } - }; + transformSpec(spec: T): void { + super.transformSpec(spec); + this.transformSeriesSpec(spec); + this._transformAxisSpec(spec); - // 将分类轴添加到轴列表中 - if (direction === 'vertical') { - // vertical: 时间轴在前,分类轴在后 - spec.axes = [spec.axes[0], categoryAxis]; - } else { - // horizontal: 时间轴在前,分类轴在后 - spec.axes = [spec.axes[0], categoryAxis]; - } - } + const direction = spec.direction ?? 'horizontal'; // 将 direction 传递给 series,并设置 xField/yField 以便轴系统收集数据 spec.series?.forEach(seriesSpec => { - if (!seriesSpec.direction) { - seriesSpec.direction = direction; - } - // 根据 direction 将 timeField 映射到 xField 或 yField // 这样轴系统才能正确收集数据 if (direction === 'vertical') {