diff --git a/docs/assets/examples/en/extension-chart/storyline-arc.md b/docs/assets/examples/en/extension-chart/storyline-arc.md new file mode 100644 index 0000000000..f9362e1037 --- /dev/null +++ b/docs/assets/examples/en/extension-chart/storyline-arc.md @@ -0,0 +1,133 @@ +--- +category: examples +group: extension chart +title: Arc Storyline +keywords: extension, storyline, arc +order: 12 +option: storylineChart +--- + +# Arc Storyline + +This example uses the `arc` case from `packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts`, retaining its images, data and theme while adapting the chart width and height to its container. + +Each run generates 3–9 nodes at random and preserves the case's long text to demonstrate how the layout handles different node counts and content lengths. + +## Key Configurations + +- `autoFit: true`: adapt width and height to the container without fixed `width` or `height` values; the demo container scales with the page at a `16 / 9` aspect ratio. +- `layout: { type: 'arc', direction: 'up' }`: use a dome arrangement. +- `titleImage.image`: show the World Cup theme image at the bottom. +- `themeColor`: use the test case's orange theme. + +## Code Demo + +```javascript livedemo +// Install @visactor/vchart-extension with the same version as @visactor/vchart. +// import VChart from '@visactor/vchart'; +// import { registerStorylineChart } from '@visactor/vchart-extension'; +// The documentation demo provides the VChartExtension global. +const { registerStorylineChart } = VChartExtension; + +const TITLE_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/title-world-cap.png'; +const SUB_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-2022.png'; +const baseData = [ + { + id: 'discover', + title: 'Discover', + content: + 'Collect the first signal and frame the story. Capture every relevant detail from the source material ' + + 'so the audience can reconstruct the same context the author had when starting the analysis.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'group', + title: 'Group', + content: + 'Arrange related facts into a compact block, removing duplicates and aligning each fragment ' + + 'to the central theme so readers can scan supporting evidence at a glance without losing context.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'connect', + title: 'Connect', + content: + 'Draw the reading path between blocks. Use repeating motifs, parallel sentence structures ' + + 'and visual cues to establish a continuous flow that walks the reader from premise to conclusion.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'emphasize', + title: 'Emphasize', + content: + 'Use image, title, and copy as one visual unit. Highlight the most important facts with typography ' + + 'weight, color contrast or motion so the eye instinctively returns to them while scanning.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'resolve', + title: 'Resolve', + content: + 'End with a clear takeaway. Summarize the lesson, point out the next decision the audience ' + + 'should make and remove any ambiguity so the story closes with a satisfying, actionable conclusion.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + } +]; +const buildData = layout => { + const count = 3 + Math.floor(Math.random() * 7); + return Array.from({ length: count }, (_, index) => { + const seed = baseData[index % baseData.length]; + // portrait 布局:附加 marker 时间节点(2012、2013…)以便沿中轴纵向展示 + const marker = layout === 'portrait' ? String(2012 + index) : undefined; + return { + ...seed, + id: `${layout}-${index}-${seed.id}`, + title: `${seed.title} ${index + 1}`, + content: [`${seed.content}`, `Layout ${layout} / Block ${index + 1} of ${count}.`], + ...(marker ? { marker } : {}) + }; + }); +}; +const themeColor = 'rgb(228,154,56)'; +const titleImage = { + image: TITLE_IMAGE_URL +}; +const layout = 'arc'; +const spec = { + type: 'storyline', + autoFit: true, + // padding: 0, + data: buildData(layout), + layout: { type: 'arc', direction: 'up' }, + titleImage, + themeColor +}; + +// Scale the demo container with the page width and preserve the original case aspect ratio. +const container = document.getElementById(CONTAINER_ID); +container.style.width = '100%'; +container.style.height = 'auto'; +container.style.contain = 'size'; +container.style.aspectRatio = '16 / 9'; + +registerStorylineChart(); +const vchart = new VChart(spec, { dom: container }); +vchart.renderSync(); + +// Just for the convenience of console debugging, DO NOT COPY! +window['vchart'] = vchart; +``` + +## Related Guide + +[Extension Chart: Storyline](/vchart/guide/tutorial_docs/Chart_Extensions/storyline) diff --git a/docs/assets/examples/en/extension-chart/storyline-clock.md b/docs/assets/examples/en/extension-chart/storyline-clock.md new file mode 100644 index 0000000000..e9b630529d --- /dev/null +++ b/docs/assets/examples/en/extension-chart/storyline-clock.md @@ -0,0 +1,112 @@ +--- +category: examples +group: extension chart +title: Clock Storyline +keywords: extension, storyline, clock +order: 11 +option: storylineChart +--- + +# Clock Storyline + +This example uses the `clock` case from `packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts`, retaining its six milestones, images and theme with shorter titles and descriptions while adapting the chart width and height to its container. + +The data covers six World Cup milestones from 1930 to 2022. Each milestone uses a short Chinese title and a one-sentence description. + +## Key Configurations + +- `autoFit: true`: adapt width and height to the container without fixed `width` or `height` values; the demo container scales with the page at a `16 / 9` aspect ratio. +- `layout: 'clock'`: arrange six World Cup nodes along a circular orbit in data order. +- `titleImage`: use a compact `180 × 60` theme image at the top to leave room for the orbit and node text. +- `title.style` and `content.style`: use a title font size of `14` with a line height of `18`, and a content font size of `11` with a line height of `14`. Keep one short sentence per node. +- `padding` and `block.padding`: reduce outer and inner spacing to give the orbit more room. +- `themeColor`: use the test case's orange theme. + +## Code Demo + +```javascript livedemo +// Install @visactor/vchart-extension with the same version as @visactor/vchart. +// import VChart from '@visactor/vchart'; +// import { registerStorylineChart } from '@visactor/vchart-extension'; +// The documentation demo provides the VChartExtension global. +const { registerStorylineChart } = VChartExtension; + +const TITLE_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/title-world-cap.png'; +const themeColor = 'rgb(228,154,56)'; +const titleImage = { + image: TITLE_IMAGE_URL, + width: 180, + height: 60 +}; +const spec = { + type: 'storyline', + autoFit: true, + padding: [16, 8, 16, 8], + block: { padding: 0 }, + layout: 'clock', + titleImage, + themeColor, + title: { + style: { fontSize: 14, lineHeight: 18 } + }, + content: { + style: { fontSize: 11, lineHeight: 14 } + }, + data: [ + { + id: 'uruguay-1930', + title: '首届世界杯', + content: '1930年,乌拉圭主场夺冠。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'brazil-1958', + title: '贝利登场', + content: '1958年,巴西首次捧杯。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'mexico-1986', + title: '马拉多纳', + content: '1986年,阿根廷再夺冠军。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'france-1998', + title: '法国首冠', + content: '1998年,法国主场夺冠。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'germany-2014', + title: '德国夺冠', + content: '2014年,德国加时赛制胜。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'qatar-2022', + title: '梅西圆梦', + content: '2022年,阿根廷点球夺冠。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + } + ] +}; + +// Scale the demo container with the page width and preserve the original case aspect ratio. +const container = document.getElementById(CONTAINER_ID); +container.style.width = '100%'; +container.style.height = 'auto'; +container.style.contain = 'size'; +container.style.aspectRatio = '16 / 9'; + +registerStorylineChart(); +const vchart = new VChart(spec, { dom: container }); +vchart.renderSync(); + +// Just for the convenience of console debugging, DO NOT COPY! +window['vchart'] = vchart; +``` + +## Related Guide + +[Extension Chart: Storyline](/vchart/guide/tutorial_docs/Chart_Extensions/storyline) diff --git a/docs/assets/examples/en/extension-chart/storyline-landscape.md b/docs/assets/examples/en/extension-chart/storyline-landscape.md new file mode 100644 index 0000000000..cd1c9f8dfd --- /dev/null +++ b/docs/assets/examples/en/extension-chart/storyline-landscape.md @@ -0,0 +1,147 @@ +--- +category: examples +group: extension chart +title: Landscape Storyline +keywords: extension, storyline, landscape +order: 9 +option: storylineChart +--- + +# Landscape Storyline + +This example uses the `landscape` case from `packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts`, retaining its images, data and theme while adapting the chart width and height to its container. + +Each run generates 3–9 nodes at random and preserves the case's long text to demonstrate how the layout handles different node counts and content lengths. + +## Key Configurations + +- `autoFit: true`: adapt width and height to the container without fixed `width` or `height` values; the demo container scales with the page at a `16 / 9` aspect ratio. +- `layout: 'landscape'`: arrange nodes along a horizontal curve. +- `line`: retain the test case's connection settings. +- `image.showBackground: false`: hide main-image background decorations. + +## Code Demo + +```javascript livedemo +// Install @visactor/vchart-extension with the same version as @visactor/vchart. +// import VChart from '@visactor/vchart'; +// import { registerStorylineChart } from '@visactor/vchart-extension'; +// The documentation demo provides the VChartExtension global. +const { registerStorylineChart } = VChartExtension; + +const TITLE_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/title-world-cap.png'; +const SUB_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-2022.png'; +const baseData = [ + { + id: 'discover', + title: 'Discover', + content: + 'Collect the first signal and frame the story. Capture every relevant detail from the source material ' + + 'so the audience can reconstruct the same context the author had when starting the analysis.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'group', + title: 'Group', + content: + 'Arrange related facts into a compact block, removing duplicates and aligning each fragment ' + + 'to the central theme so readers can scan supporting evidence at a glance without losing context.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'connect', + title: 'Connect', + content: + 'Draw the reading path between blocks. Use repeating motifs, parallel sentence structures ' + + 'and visual cues to establish a continuous flow that walks the reader from premise to conclusion.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'emphasize', + title: 'Emphasize', + content: + 'Use image, title, and copy as one visual unit. Highlight the most important facts with typography ' + + 'weight, color contrast or motion so the eye instinctively returns to them while scanning.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'resolve', + title: 'Resolve', + content: + 'End with a clear takeaway. Summarize the lesson, point out the next decision the audience ' + + 'should make and remove any ambiguity so the story closes with a satisfying, actionable conclusion.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + } +]; +const buildData = layout => { + const count = 3 + Math.floor(Math.random() * 7); + return Array.from({ length: count }, (_, index) => { + const seed = baseData[index % baseData.length]; + // portrait 布局:附加 marker 时间节点(2012、2013…)以便沿中轴纵向展示 + const marker = layout === 'portrait' ? String(2012 + index) : undefined; + return { + ...seed, + id: `${layout}-${index}-${seed.id}`, + title: `${seed.title} ${index + 1}`, + content: [`${seed.content}`, `Layout ${layout} / Block ${index + 1} of ${count}.`], + ...(marker ? { marker } : {}) + }; + }); +}; +const themeColor = 'rgb(228,154,56)'; +const titleImage = { + image: TITLE_IMAGE_URL +}; +const commonLine = { + type: 'line', + showArrow: true, + style: { + lineWidth: 1.5, + lineCap: 'round', + lineJoin: 'round', + lineDash: [6, 5] + } +}; +const layout = 'landscape'; +const spec = { + type: 'storyline', + autoFit: true, + padding: 20, + data: buildData(layout), + layout, + titleImage, + themeColor, + line: commonLine, + image: { + showBackground: false + } +}; + +// Scale the demo container with the page width and preserve the original case aspect ratio. +const container = document.getElementById(CONTAINER_ID); +container.style.width = '100%'; +container.style.height = 'auto'; +container.style.contain = 'size'; +container.style.aspectRatio = '16 / 9'; + +registerStorylineChart(); +const vchart = new VChart(spec, { dom: container }); +vchart.renderSync(); + +// Just for the convenience of console debugging, DO NOT COPY! +window['vchart'] = vchart; +``` + +## Related Guide + +[Extension Chart: Storyline](/vchart/guide/tutorial_docs/Chart_Extensions/storyline) diff --git a/docs/assets/examples/en/extension-chart/storyline-portrait.md b/docs/assets/examples/en/extension-chart/storyline-portrait.md new file mode 100644 index 0000000000..8279bb117f --- /dev/null +++ b/docs/assets/examples/en/extension-chart/storyline-portrait.md @@ -0,0 +1,132 @@ +--- +category: examples +group: extension chart +title: Portrait Storyline +keywords: extension, storyline, portrait +order: 10 +option: storylineChart +--- + +# Portrait Storyline + +This example uses the `portrait` case from `packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts`, retaining its images, data and theme while adapting the chart width and height to its container. + +Each run generates 3–9 nodes at random and preserves the case's long text to demonstrate how the layout handles different node counts and content lengths. + +## Key Configurations + +- `autoFit: true`: adapt width and height to the container without fixed `width` or `height` values; the demo container scales with the page at a `9 / 16` aspect ratio. +- `layout: 'portrait'`: alternate images and text around the central axis. +- `data[].marker`: show time labels starting from 2012. +- `data[].subImage`: draw the decorative image behind each main image. + +## Code Demo + +```javascript livedemo +// Install @visactor/vchart-extension with the same version as @visactor/vchart. +// import VChart from '@visactor/vchart'; +// import { registerStorylineChart } from '@visactor/vchart-extension'; +// The documentation demo provides the VChartExtension global. +const { registerStorylineChart } = VChartExtension; + +const TITLE_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/title-world-cap.png'; +const SUB_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-2022.png'; +const baseData = [ + { + id: 'discover', + title: 'Discover', + content: + 'Collect the first signal and frame the story. Capture every relevant detail from the source material ' + + 'so the audience can reconstruct the same context the author had when starting the analysis.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'group', + title: 'Group', + content: + 'Arrange related facts into a compact block, removing duplicates and aligning each fragment ' + + 'to the central theme so readers can scan supporting evidence at a glance without losing context.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'connect', + title: 'Connect', + content: + 'Draw the reading path between blocks. Use repeating motifs, parallel sentence structures ' + + 'and visual cues to establish a continuous flow that walks the reader from premise to conclusion.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'emphasize', + title: 'Emphasize', + content: + 'Use image, title, and copy as one visual unit. Highlight the most important facts with typography ' + + 'weight, color contrast or motion so the eye instinctively returns to them while scanning.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'resolve', + title: 'Resolve', + content: + 'End with a clear takeaway. Summarize the lesson, point out the next decision the audience ' + + 'should make and remove any ambiguity so the story closes with a satisfying, actionable conclusion.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + } +]; +const buildData = layout => { + const count = 3 + Math.floor(Math.random() * 7); + return Array.from({ length: count }, (_, index) => { + const seed = baseData[index % baseData.length]; + // portrait 布局:附加 marker 时间节点(2012、2013…)以便沿中轴纵向展示 + const marker = layout === 'portrait' ? String(2012 + index) : undefined; + return { + ...seed, + id: `${layout}-${index}-${seed.id}`, + title: `${seed.title} ${index + 1}`, + content: [`${seed.content}`, `Layout ${layout} / Block ${index + 1} of ${count}.`], + ...(marker ? { marker } : {}) + }; + }); +}; +const themeColor = 'rgb(228,154,56)'; +const titleImage = { + image: TITLE_IMAGE_URL +}; +const layout = 'portrait'; +const spec = { + type: 'storyline', + autoFit: true, + data: buildData(layout), + layout, + titleImage, + themeColor +}; + +// Scale the demo container with the page width and preserve the original case aspect ratio. +const container = document.getElementById(CONTAINER_ID); +container.style.width = '100%'; +container.style.height = 'auto'; +container.style.contain = 'size'; +container.style.aspectRatio = '9 / 16'; + +registerStorylineChart(); +const vchart = new VChart(spec, { dom: container }); +vchart.renderSync(); + +// Just for the convenience of console debugging, DO NOT COPY! +window['vchart'] = vchart; +``` + +## Related Guide + +[Extension Chart: Storyline](/vchart/guide/tutorial_docs/Chart_Extensions/storyline) diff --git a/docs/assets/examples/en/extension-chart/storyline-wing.md b/docs/assets/examples/en/extension-chart/storyline-wing.md new file mode 100644 index 0000000000..49394de9cb --- /dev/null +++ b/docs/assets/examples/en/extension-chart/storyline-wing.md @@ -0,0 +1,135 @@ +--- +category: examples +group: extension chart +title: Wing Storyline +keywords: extension, storyline, wing +order: 13 +option: storylineChart +--- + +# Wing Storyline + +This example uses the `wing` case from `packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts`, retaining its images, data and theme while adapting the chart width and height to its container. + +Each run generates 3–9 nodes at random and preserves the case's long text to demonstrate how the layout handles different node counts and content lengths. + +## Key Configurations + +- `autoFit: true`: adapt width and height to the container without fixed `width` or `height` values; the demo container scales with the page at a `9 / 16` aspect ratio. +- `layout: { type: 'wing', direction: 'left' }`: anchor the wing layout on the left. +- `titleImage.image`: use the test case's World Cup theme image. +- `titleImage.style.fill: 'transparent'`: make the theme image background transparent while keeping the image visible. +- `themeColor`: use the test case's orange theme. + +## Code Demo + +```javascript livedemo +// Install @visactor/vchart-extension with the same version as @visactor/vchart. +// import VChart from '@visactor/vchart'; +// import { registerStorylineChart } from '@visactor/vchart-extension'; +// The documentation demo provides the VChartExtension global. +const { registerStorylineChart } = VChartExtension; + +const TITLE_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/title-world-cap.png'; +const SUB_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-2022.png'; +const baseData = [ + { + id: 'discover', + title: 'Discover', + content: + 'Collect the first signal and frame the story. Capture every relevant detail from the source material ' + + 'so the audience can reconstruct the same context the author had when starting the analysis.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'group', + title: 'Group', + content: + 'Arrange related facts into a compact block, removing duplicates and aligning each fragment ' + + 'to the central theme so readers can scan supporting evidence at a glance without losing context.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'connect', + title: 'Connect', + content: + 'Draw the reading path between blocks. Use repeating motifs, parallel sentence structures ' + + 'and visual cues to establish a continuous flow that walks the reader from premise to conclusion.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'emphasize', + title: 'Emphasize', + content: + 'Use image, title, and copy as one visual unit. Highlight the most important facts with typography ' + + 'weight, color contrast or motion so the eye instinctively returns to them while scanning.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'resolve', + title: 'Resolve', + content: + 'End with a clear takeaway. Summarize the lesson, point out the next decision the audience ' + + 'should make and remove any ambiguity so the story closes with a satisfying, actionable conclusion.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + } +]; +const buildData = layout => { + const count = 3 + Math.floor(Math.random() * 7); + return Array.from({ length: count }, (_, index) => { + const seed = baseData[index % baseData.length]; + // portrait 布局:附加 marker 时间节点(2012、2013…)以便沿中轴纵向展示 + const marker = layout === 'portrait' ? String(2012 + index) : undefined; + return { + ...seed, + id: `${layout}-${index}-${seed.id}`, + title: `${seed.title} ${index + 1}`, + content: [`${seed.content}`, `Layout ${layout} / Block ${index + 1} of ${count}.`], + ...(marker ? { marker } : {}) + }; + }); +}; +const themeColor = 'rgb(228,154,56)'; +const titleImage = { + image: TITLE_IMAGE_URL, + style: { fill: 'transparent' } +}; +const layout = 'wing'; +const spec = { + type: 'storyline', + autoFit: true, + // padding: [40, 40, 40, 40], + data: buildData(layout), + layout: { type: 'wing', direction: 'left' }, + titleImage, + themeColor +}; + +// Scale the demo container with the page width and preserve the original case aspect ratio. +const container = document.getElementById(CONTAINER_ID); +container.style.width = '100%'; +container.style.height = 'auto'; +container.style.contain = 'size'; +container.style.aspectRatio = '9 / 16'; + +registerStorylineChart(); +const vchart = new VChart(spec, { dom: container }); +vchart.renderSync(); + +// Just for the convenience of console debugging, DO NOT COPY! +window['vchart'] = vchart; +``` + +## Related Guide + +[Extension Chart: Storyline](/vchart/guide/tutorial_docs/Chart_Extensions/storyline) diff --git a/docs/assets/examples/menu.json b/docs/assets/examples/menu.json index 3167ad7df5..d499c70d49 100644 --- a/docs/assets/examples/menu.json +++ b/docs/assets/examples/menu.json @@ -2082,6 +2082,41 @@ "zh": "时序散点图-支持KDE背景", "en": "sequence-scatter-kde" } + }, + { + "path": "storyline-landscape", + "title": { + "zh": "横向叙事图", + "en": "Landscape Storyline" + } + }, + { + "path": "storyline-portrait", + "title": { + "zh": "纵向叙事图", + "en": "Portrait Storyline" + } + }, + { + "path": "storyline-clock", + "title": { + "zh": "时钟叙事图", + "en": "Clock Storyline" + } + }, + { + "path": "storyline-arc", + "title": { + "zh": "弧形叙事图", + "en": "Arc Storyline" + } + }, + { + "path": "storyline-wing", + "title": { + "zh": "翼形叙事图", + "en": "Wing Storyline" + } } ] }, @@ -3351,4 +3386,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/docs/assets/examples/zh/extension-chart/storyline-arc.md b/docs/assets/examples/zh/extension-chart/storyline-arc.md new file mode 100644 index 0000000000..e90ca8d654 --- /dev/null +++ b/docs/assets/examples/zh/extension-chart/storyline-arc.md @@ -0,0 +1,133 @@ +--- +category: examples +group: extension chart +title: 弧形叙事图 +keywords: extension, storyline, arc +order: 12 +option: storylineChart +--- + +# 弧形叙事图 + +本示例沿用 `packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts` 中的 `arc` case,保留原始图片、数据和配色,画布宽高自适应容器。 + +每次运行随机生成 3–9 个节点,保留测试 case 的长文本,用于观察布局对不同节点数量和内容长度的处理。 + +## 关键配置 + +- `autoFit: true`:宽高自适应容器,不设置固定的 `width`、`height`;演示容器按 `16 / 9` 的宽高比随页面缩放。 +- `layout: { type: 'arc', direction: 'up' }`:穹顶布局。 +- `titleImage.image`:底部世界杯主题图。 +- `themeColor`:沿用测试 case 的橙色主题。 + +## 代码演示 + +```javascript livedemo +// 在业务中使用时,请安装与 @visactor/vchart 版本一致的 @visactor/vchart-extension。 +// import VChart from '@visactor/vchart'; +// import { registerStorylineChart } from '@visactor/vchart-extension'; +// 文档演示环境提供 VChartExtension 全局变量。 +const { registerStorylineChart } = VChartExtension; + +const TITLE_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/title-world-cap.png'; +const SUB_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-2022.png'; +const baseData = [ + { + id: 'discover', + title: 'Discover', + content: + 'Collect the first signal and frame the story. Capture every relevant detail from the source material ' + + 'so the audience can reconstruct the same context the author had when starting the analysis.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'group', + title: 'Group', + content: + 'Arrange related facts into a compact block, removing duplicates and aligning each fragment ' + + 'to the central theme so readers can scan supporting evidence at a glance without losing context.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'connect', + title: 'Connect', + content: + 'Draw the reading path between blocks. Use repeating motifs, parallel sentence structures ' + + 'and visual cues to establish a continuous flow that walks the reader from premise to conclusion.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'emphasize', + title: 'Emphasize', + content: + 'Use image, title, and copy as one visual unit. Highlight the most important facts with typography ' + + 'weight, color contrast or motion so the eye instinctively returns to them while scanning.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'resolve', + title: 'Resolve', + content: + 'End with a clear takeaway. Summarize the lesson, point out the next decision the audience ' + + 'should make and remove any ambiguity so the story closes with a satisfying, actionable conclusion.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + } +]; +const buildData = layout => { + const count = 3 + Math.floor(Math.random() * 7); + return Array.from({ length: count }, (_, index) => { + const seed = baseData[index % baseData.length]; + // portrait 布局:附加 marker 时间节点(2012、2013…)以便沿中轴纵向展示 + const marker = layout === 'portrait' ? String(2012 + index) : undefined; + return { + ...seed, + id: `${layout}-${index}-${seed.id}`, + title: `${seed.title} ${index + 1}`, + content: [`${seed.content}`, `Layout ${layout} / Block ${index + 1} of ${count}.`], + ...(marker ? { marker } : {}) + }; + }); +}; +const themeColor = 'rgb(228,154,56)'; +const titleImage = { + image: TITLE_IMAGE_URL +}; +const layout = 'arc'; +const spec = { + type: 'storyline', + autoFit: true, + // padding: 0, + data: buildData(layout), + layout: { type: 'arc', direction: 'up' }, + titleImage, + themeColor +}; + +// 演示容器随页面宽度缩放,并保留原 case 的宽高比例。 +const container = document.getElementById(CONTAINER_ID); +container.style.width = '100%'; +container.style.height = 'auto'; +container.style.contain = 'size'; +container.style.aspectRatio = '16 / 9'; + +registerStorylineChart(); +const vchart = new VChart(spec, { dom: container }); +vchart.renderSync(); + +// Just for the convenience of console debugging, DO NOT COPY! +window['vchart'] = vchart; +``` + +## 相关教程 + +[扩展图表:叙事图](/vchart/guide/tutorial_docs/Chart_Extensions/storyline) diff --git a/docs/assets/examples/zh/extension-chart/storyline-clock.md b/docs/assets/examples/zh/extension-chart/storyline-clock.md new file mode 100644 index 0000000000..6d43336d88 --- /dev/null +++ b/docs/assets/examples/zh/extension-chart/storyline-clock.md @@ -0,0 +1,112 @@ +--- +category: examples +group: extension chart +title: 时钟叙事图 +keywords: extension, storyline, clock +order: 11 +option: storylineChart +--- + +# 时钟叙事图 + +本示例沿用 `packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts` 中的 `clock` case,保留六个历史节点、原始图片和配色,精简标题与正文,画布宽高自适应容器。 + +展示 1930 至 2022 年的六个世界杯历史节点。 + +## 关键配置 + +- `autoFit: true`:宽高自适应容器,不设置固定的 `width`、`height`;演示容器按 `16 / 9` 的宽高比随页面缩放。 +- `layout: 'clock'`:按数据顺序沿圆形轨道排列六个世界杯节点。 +- `titleImage`:顶部世界杯主题图缩小至 `180 × 60`,为圆环和节点文字留出空间。 +- `title.style`、`content.style`:标题字号 `14`、行高 `18`;正文字号 `11`、行高 `14`,每个节点只保留一句短说明。 +- `padding`、`block.padding`:缩小外边距和内部留白,扩大圆环的可用区域。 +- `themeColor`:沿用测试 case 的橙色主题。 + +## 代码演示 + +```javascript livedemo +// 在业务中使用时,请安装与 @visactor/vchart 版本一致的 @visactor/vchart-extension。 +// import VChart from '@visactor/vchart'; +// import { registerStorylineChart } from '@visactor/vchart-extension'; +// 文档演示环境提供 VChartExtension 全局变量。 +const { registerStorylineChart } = VChartExtension; + +const TITLE_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/title-world-cap.png'; +const themeColor = 'rgb(228,154,56)'; +const titleImage = { + image: TITLE_IMAGE_URL, + width: 180, + height: 60 +}; +const spec = { + type: 'storyline', + autoFit: true, + padding: [16, 8, 16, 8], + block: { padding: 0 }, + layout: 'clock', + titleImage, + themeColor, + title: { + style: { fontSize: 14, lineHeight: 18 } + }, + content: { + style: { fontSize: 11, lineHeight: 14 } + }, + data: [ + { + id: 'uruguay-1930', + title: '首届世界杯', + content: '1930年,乌拉圭主场夺冠。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'brazil-1958', + title: '贝利登场', + content: '1958年,巴西首次捧杯。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'mexico-1986', + title: '马拉多纳', + content: '1986年,阿根廷再夺冠军。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'france-1998', + title: '法国首冠', + content: '1998年,法国主场夺冠。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'germany-2014', + title: '德国夺冠', + content: '2014年,德国加时赛制胜。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'qatar-2022', + title: '梅西圆梦', + content: '2022年,阿根廷点球夺冠。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + } + ] +}; + +// 演示容器随页面宽度缩放,并保留原 case 的宽高比例。 +const container = document.getElementById(CONTAINER_ID); +container.style.width = '100%'; +container.style.height = 'auto'; +container.style.contain = 'size'; +container.style.aspectRatio = '16 / 9'; + +registerStorylineChart(); +const vchart = new VChart(spec, { dom: container }); +vchart.renderSync(); + +// Just for the convenience of console debugging, DO NOT COPY! +window['vchart'] = vchart; +``` + +## 相关教程 + +[扩展图表:叙事图](/vchart/guide/tutorial_docs/Chart_Extensions/storyline) diff --git a/docs/assets/examples/zh/extension-chart/storyline-landscape.md b/docs/assets/examples/zh/extension-chart/storyline-landscape.md new file mode 100644 index 0000000000..b46a5e5990 --- /dev/null +++ b/docs/assets/examples/zh/extension-chart/storyline-landscape.md @@ -0,0 +1,147 @@ +--- +category: examples +group: extension chart +title: 横向叙事图 +keywords: extension, storyline, landscape +order: 9 +option: storylineChart +--- + +# 横向叙事图 + +本示例沿用 `packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts` 中的 `landscape` case,保留原始图片、数据和配色,画布宽高自适应容器。 + +每次运行随机生成 3–9 个节点,保留测试 case 的长文本,用于观察布局对不同节点数量和内容长度的处理。 + +## 关键配置 + +- `autoFit: true`:宽高自适应容器,不设置固定的 `width`、`height`;演示容器按 `16 / 9` 的宽高比随页面缩放。 +- `layout: 'landscape'`:沿横向曲线排列节点。 +- `line`:保留测试 case 的连线配置。 +- `image.showBackground: false`:隐藏主图背景装饰。 + +## 代码演示 + +```javascript livedemo +// 在业务中使用时,请安装与 @visactor/vchart 版本一致的 @visactor/vchart-extension。 +// import VChart from '@visactor/vchart'; +// import { registerStorylineChart } from '@visactor/vchart-extension'; +// 文档演示环境提供 VChartExtension 全局变量。 +const { registerStorylineChart } = VChartExtension; + +const TITLE_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/title-world-cap.png'; +const SUB_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-2022.png'; +const baseData = [ + { + id: 'discover', + title: 'Discover', + content: + 'Collect the first signal and frame the story. Capture every relevant detail from the source material ' + + 'so the audience can reconstruct the same context the author had when starting the analysis.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'group', + title: 'Group', + content: + 'Arrange related facts into a compact block, removing duplicates and aligning each fragment ' + + 'to the central theme so readers can scan supporting evidence at a glance without losing context.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'connect', + title: 'Connect', + content: + 'Draw the reading path between blocks. Use repeating motifs, parallel sentence structures ' + + 'and visual cues to establish a continuous flow that walks the reader from premise to conclusion.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'emphasize', + title: 'Emphasize', + content: + 'Use image, title, and copy as one visual unit. Highlight the most important facts with typography ' + + 'weight, color contrast or motion so the eye instinctively returns to them while scanning.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'resolve', + title: 'Resolve', + content: + 'End with a clear takeaway. Summarize the lesson, point out the next decision the audience ' + + 'should make and remove any ambiguity so the story closes with a satisfying, actionable conclusion.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + } +]; +const buildData = layout => { + const count = 3 + Math.floor(Math.random() * 7); + return Array.from({ length: count }, (_, index) => { + const seed = baseData[index % baseData.length]; + // portrait 布局:附加 marker 时间节点(2012、2013…)以便沿中轴纵向展示 + const marker = layout === 'portrait' ? String(2012 + index) : undefined; + return { + ...seed, + id: `${layout}-${index}-${seed.id}`, + title: `${seed.title} ${index + 1}`, + content: [`${seed.content}`, `Layout ${layout} / Block ${index + 1} of ${count}.`], + ...(marker ? { marker } : {}) + }; + }); +}; +const themeColor = 'rgb(228,154,56)'; +const titleImage = { + image: TITLE_IMAGE_URL +}; +const commonLine = { + type: 'line', + showArrow: true, + style: { + lineWidth: 1.5, + lineCap: 'round', + lineJoin: 'round', + lineDash: [6, 5] + } +}; +const layout = 'landscape'; +const spec = { + type: 'storyline', + autoFit: true, + padding: 20, + data: buildData(layout), + layout, + titleImage, + themeColor, + line: commonLine, + image: { + showBackground: false + } +}; + +// 演示容器随页面宽度缩放,并保留原 case 的宽高比例。 +const container = document.getElementById(CONTAINER_ID); +container.style.width = '100%'; +container.style.height = 'auto'; +container.style.contain = 'size'; +container.style.aspectRatio = '16 / 9'; + +registerStorylineChart(); +const vchart = new VChart(spec, { dom: container }); +vchart.renderSync(); + +// Just for the convenience of console debugging, DO NOT COPY! +window['vchart'] = vchart; +``` + +## 相关教程 + +[扩展图表:叙事图](/vchart/guide/tutorial_docs/Chart_Extensions/storyline) diff --git a/docs/assets/examples/zh/extension-chart/storyline-portrait.md b/docs/assets/examples/zh/extension-chart/storyline-portrait.md new file mode 100644 index 0000000000..cd88db733b --- /dev/null +++ b/docs/assets/examples/zh/extension-chart/storyline-portrait.md @@ -0,0 +1,132 @@ +--- +category: examples +group: extension chart +title: 纵向叙事图 +keywords: extension, storyline, portrait +order: 10 +option: storylineChart +--- + +# 纵向叙事图 + +本示例沿用 `packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts` 中的 `portrait` case,保留原始图片、数据和配色,画布宽高自适应容器。 + +每次运行随机生成 3–9 个节点,保留测试 case 的长文本,用于观察布局对不同节点数量和内容长度的处理。 + +## 关键配置 + +- `autoFit: true`:宽高自适应容器,不设置固定的 `width`、`height`;演示容器按 `9 / 16` 的宽高比随页面缩放。 +- `layout: 'portrait'`:图片和文字沿中轴左右交替排列。 +- `data[].marker`:显示从 2012 开始的时间标记。 +- `data[].subImage`:绘制主图后方的装饰图片。 + +## 代码演示 + +```javascript livedemo +// 在业务中使用时,请安装与 @visactor/vchart 版本一致的 @visactor/vchart-extension。 +// import VChart from '@visactor/vchart'; +// import { registerStorylineChart } from '@visactor/vchart-extension'; +// 文档演示环境提供 VChartExtension 全局变量。 +const { registerStorylineChart } = VChartExtension; + +const TITLE_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/title-world-cap.png'; +const SUB_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-2022.png'; +const baseData = [ + { + id: 'discover', + title: 'Discover', + content: + 'Collect the first signal and frame the story. Capture every relevant detail from the source material ' + + 'so the audience can reconstruct the same context the author had when starting the analysis.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'group', + title: 'Group', + content: + 'Arrange related facts into a compact block, removing duplicates and aligning each fragment ' + + 'to the central theme so readers can scan supporting evidence at a glance without losing context.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'connect', + title: 'Connect', + content: + 'Draw the reading path between blocks. Use repeating motifs, parallel sentence structures ' + + 'and visual cues to establish a continuous flow that walks the reader from premise to conclusion.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'emphasize', + title: 'Emphasize', + content: + 'Use image, title, and copy as one visual unit. Highlight the most important facts with typography ' + + 'weight, color contrast or motion so the eye instinctively returns to them while scanning.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'resolve', + title: 'Resolve', + content: + 'End with a clear takeaway. Summarize the lesson, point out the next decision the audience ' + + 'should make and remove any ambiguity so the story closes with a satisfying, actionable conclusion.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + } +]; +const buildData = layout => { + const count = 3 + Math.floor(Math.random() * 7); + return Array.from({ length: count }, (_, index) => { + const seed = baseData[index % baseData.length]; + // portrait 布局:附加 marker 时间节点(2012、2013…)以便沿中轴纵向展示 + const marker = layout === 'portrait' ? String(2012 + index) : undefined; + return { + ...seed, + id: `${layout}-${index}-${seed.id}`, + title: `${seed.title} ${index + 1}`, + content: [`${seed.content}`, `Layout ${layout} / Block ${index + 1} of ${count}.`], + ...(marker ? { marker } : {}) + }; + }); +}; +const themeColor = 'rgb(228,154,56)'; +const titleImage = { + image: TITLE_IMAGE_URL +}; +const layout = 'portrait'; +const spec = { + type: 'storyline', + autoFit: true, + data: buildData(layout), + layout, + titleImage, + themeColor +}; + +// 演示容器随页面宽度缩放,并保留原 case 的宽高比例。 +const container = document.getElementById(CONTAINER_ID); +container.style.width = '100%'; +container.style.height = 'auto'; +container.style.contain = 'size'; +container.style.aspectRatio = '9 / 16'; + +registerStorylineChart(); +const vchart = new VChart(spec, { dom: container }); +vchart.renderSync(); + +// Just for the convenience of console debugging, DO NOT COPY! +window['vchart'] = vchart; +``` + +## 相关教程 + +[扩展图表:叙事图](/vchart/guide/tutorial_docs/Chart_Extensions/storyline) diff --git a/docs/assets/examples/zh/extension-chart/storyline-wing.md b/docs/assets/examples/zh/extension-chart/storyline-wing.md new file mode 100644 index 0000000000..d8bca8f4e3 --- /dev/null +++ b/docs/assets/examples/zh/extension-chart/storyline-wing.md @@ -0,0 +1,135 @@ +--- +category: examples +group: extension chart +title: 翼形叙事图 +keywords: extension, storyline, wing +order: 13 +option: storylineChart +--- + +# 翼形叙事图 + +本示例沿用 `packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts` 中的 `wing` case,保留原始图片、数据和配色,画布宽高自适应容器。 + +每次运行随机生成 3–9 个节点,保留测试 case 的长文本,用于观察布局对不同节点数量和内容长度的处理。 + +## 关键配置 + +- `autoFit: true`:宽高自适应容器,不设置固定的 `width`、`height`;演示容器按 `9 / 16` 的宽高比随页面缩放。 +- `layout: { type: 'wing', direction: 'left' }`:左侧锚定的翼形布局。 +- `titleImage.image`:使用测试 case 的世界杯主题图。 +- `titleImage.style.fill: 'transparent'`:将主题图的背景填充设为透明,保留图片内容。 +- `themeColor`:沿用测试 case 的橙色主题。 + +## 代码演示 + +```javascript livedemo +// 在业务中使用时,请安装与 @visactor/vchart 版本一致的 @visactor/vchart-extension。 +// import VChart from '@visactor/vchart'; +// import { registerStorylineChart } from '@visactor/vchart-extension'; +// 文档演示环境提供 VChartExtension 全局变量。 +const { registerStorylineChart } = VChartExtension; + +const TITLE_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/title-world-cap.png'; +const SUB_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-2022.png'; +const baseData = [ + { + id: 'discover', + title: 'Discover', + content: + 'Collect the first signal and frame the story. Capture every relevant detail from the source material ' + + 'so the audience can reconstruct the same context the author had when starting the analysis.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'group', + title: 'Group', + content: + 'Arrange related facts into a compact block, removing duplicates and aligning each fragment ' + + 'to the central theme so readers can scan supporting evidence at a glance without losing context.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'connect', + title: 'Connect', + content: + 'Draw the reading path between blocks. Use repeating motifs, parallel sentence structures ' + + 'and visual cues to establish a continuous flow that walks the reader from premise to conclusion.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'emphasize', + title: 'Emphasize', + content: + 'Use image, title, and copy as one visual unit. Highlight the most important facts with typography ' + + 'weight, color contrast or motion so the eye instinctively returns to them while scanning.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'resolve', + title: 'Resolve', + content: + 'End with a clear takeaway. Summarize the lesson, point out the next decision the audience ' + + 'should make and remove any ambiguity so the story closes with a satisfying, actionable conclusion.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + } +]; +const buildData = layout => { + const count = 3 + Math.floor(Math.random() * 7); + return Array.from({ length: count }, (_, index) => { + const seed = baseData[index % baseData.length]; + // portrait 布局:附加 marker 时间节点(2012、2013…)以便沿中轴纵向展示 + const marker = layout === 'portrait' ? String(2012 + index) : undefined; + return { + ...seed, + id: `${layout}-${index}-${seed.id}`, + title: `${seed.title} ${index + 1}`, + content: [`${seed.content}`, `Layout ${layout} / Block ${index + 1} of ${count}.`], + ...(marker ? { marker } : {}) + }; + }); +}; +const themeColor = 'rgb(228,154,56)'; +const titleImage = { + image: TITLE_IMAGE_URL, + style: { fill: 'transparent' } +}; +const layout = 'wing'; +const spec = { + type: 'storyline', + autoFit: true, + // padding: [40, 40, 40, 40], + data: buildData(layout), + layout: { type: 'wing', direction: 'left' }, + titleImage, + themeColor +}; + +// 演示容器随页面宽度缩放,并保留原 case 的宽高比例。 +const container = document.getElementById(CONTAINER_ID); +container.style.width = '100%'; +container.style.height = 'auto'; +container.style.contain = 'size'; +container.style.aspectRatio = '9 / 16'; + +registerStorylineChart(); +const vchart = new VChart(spec, { dom: container }); +vchart.renderSync(); + +// Just for the convenience of console debugging, DO NOT COPY! +window['vchart'] = vchart; +``` + +## 相关教程 + +[扩展图表:叙事图](/vchart/guide/tutorial_docs/Chart_Extensions/storyline) diff --git a/docs/assets/guide/en/tutorial_docs/Chart_Extensions/storyline.md b/docs/assets/guide/en/tutorial_docs/Chart_Extensions/storyline.md new file mode 100644 index 0000000000..d9d3ab76dd --- /dev/null +++ b/docs/assets/guide/en/tutorial_docs/Chart_Extensions/storyline.md @@ -0,0 +1,180 @@ +# Extension Chart: Storyline + +A Storyline chart combines images, titles and descriptions into a sequence of nodes. Use it for product evolution, project milestones and narrative sequences. Nodes follow the order of the `data` array; the chart does not sort dates or use a time scale to determine spacing. + +Storyline is provided by `@visactor/vchart-extension` and supports five layouts: `landscape`, `portrait`, `clock`, `arc` and `wing`. + +See the [Storyline options](/vchart/option/storylineChart) for all fields, defaults and layout-specific behavior. + +## Registration and Usage + +Install `@visactor/vchart-extension` with the same version as `@visactor/vchart`, then register the chart before creating an instance: + +```js +import VChart from '@visactor/vchart'; +import { registerStorylineChart } from '@visactor/vchart-extension'; + +registerStorylineChart(); + +const spec = { + type: 'storyline', + width: 1000, + height: 560, + layout: 'landscape', + themeColor: '#3b82a0', + title: { style: { fontSize: 20 } }, + content: { style: { fontSize: 14, lineHeight: 21 } }, + data: [ + { id: 'discover', title: 'Discover', content: 'Talk to users and identify the problem.' }, + { id: 'design', title: 'Design', content: ['Outline the key workflows.', 'Collect feedback on a prototype.'] }, + { id: 'launch', title: 'Launch', content: 'Release the first version and improve the experience.' } + ] +}; + +const vchart = new VChart(spec, { dom: 'chart' }); +vchart.renderSync(); +``` + +Provide a container with `id="chart"` on the page. For TypeScript, import `IStorylineSpec` from the extension package to type the specification. + +When loading through a CDN, use the extension's global object and pass the page's VChart constructor to the registration function: + +```html +
+ + + +``` + +For production, pin both CDN URLs to the same package version that includes Storyline. + +## Data Structure + +`data` takes an array of nodes directly. It does not use the standard `{ id, values }` dataset wrapper and does not require `xField`, `yField` or `series`. + +| Field | Type | Description | +| ---------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `id` | `string \| number` | Optional node identifier; defaults to the data index. | +| `title` | `string` | Node title. | +| `content` | `string \| string[]` | Node description; array entries are joined with line breaks. | +| `image` | `string \| HTMLImageElement \| HTMLCanvasElement` | Main image: an image URL, data URL, image element or canvas element. | +| `subImage` | Same as `image` | Offset decorative image behind the main image in the portrait layout; omitted when absent. | +| `marker` | `string` | Portrait-only time marker, such as `'2024'`, with characters stacked vertically on the axis. | +| `datum` | `unknown` | Optional original business data. | + +Images, titles and descriptions are optional. Sort the array before passing it to the chart when chronological order is needed. To update nodes or change layouts, call `vchart.updateSpec(nextSpec)` with a complete specification containing the new `data` array. + +## Layouts + +`layout` accepts a layout name or an object containing `type`. The default is `landscape`. + +See the [layout options](/vchart/option/storylineChart#layout) for full types, parameter applicability and how direction, angles and padding work together. + +| Layout | Arrangement | Use cases | +| ----------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------- | +| `landscape` | Images arranged horizontally, alternating text above and below, with a curve connecting the nodes. | Horizontal processes, product evolution. | +| `portrait` | Images and text alternate on either side of a vertical axis, with optional axis markers. | Milestones, development history. | +| `clock` | Nodes arranged clockwise around an orbit, with text extending outward. | Stage reviews, recurring processes. | +| `arc` | Nodes arranged along an arc around a theme image. | Growth stories, themed infographics. | +| `wing` | Circular images along a wing-shaped path, with text on either side. | Vertical narratives, project progress. | + +Use `direction` to switch the arc between a dome and a bowl: + +```js +layout: { + type: 'arc', + direction: 'up', // 'up': dome with the theme image at the bottom; 'down': bowl with the image at the top + radiusRatio: 0.9 +} +``` + +For a wing layout, use `layout: { type: 'wing', direction: 'left' }`. Its direction can be `left` (the default) or `right`. + +The current specialized layouts support different geometry options: + +- `arc` supports `radiusRatio`, `startAngle` and `endAngle`, with angles in degrees. Default start/end angles are `200` / `340` for `up` and `20` / `160` for `down`. +- `wing` also supports those three options. Default start/end angles are `-70` / `70` for `left` and `110` / `250` for `right`; `radiusRatio` defaults to `0.92`. +- `clock` computes its own angles and radius for the full orbit and currently ignores those three options. +- `layout.padding` accepts a number or `[top, right, bottom, left]` for internal spacing in landscape and portrait node layouts. Use top-level `padding` for chart margins. + +## Images and Text + +`title` configures the title mark for each node; the text comes from `data[].title`. Descriptions come from `data[].content`, with styles configured through `content`. This `title` does not use the `text` and `subtext` fields of the standard chart title component. + +```js +title: { + style: { fontSize: 20, lineHeight: 27, fontWeight: 'bold', fill: '#23364d' } +}, +content: { + style: { fontSize: 14, lineHeight: 21, fill: '#526174' } +}, +image: { + showBackground: true, + style: { opacity: 0.95 } +} +``` + +| Option | Description | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `themeColor` | Default theme color for connecting lines, axes and image decoration; defaults to `#e8543d`. | +| `title.style`, `content.style` | Node text styles; use each mark's `visible: false` to hide it. Titles have a two-line limit; description space depends on the layout. | +| `image.width`, `image.height` | Image dimensions in portrait, arc and wing layouts. Landscape uses `height` and computes the width; clock computes node image dimensions automatically. | +| `image.showBackground` | Image background decoration: off by default in landscape and portrait, on in the other layouts. Does not control `subImage` visibility. | +| `image.style` | Image mark styles, such as `opacity`. | +| `titleImage` | Theme image with `image`, `width`, `height`, `visible` and `style` options. Its placement depends on the layout. | +| `marker` | Portrait only: use `visible` to toggle time markers and `style.fontSize` / `style.lineHeight` to set their size and line height. | + +For example, add a theme image to an arc layout: + +```js +titleImage: { + image: 'https://example.com/story-title.png', // Replace with your own image URL + width: 320, + height: 140 +} +``` + +See the [image options](/vchart/option/storylineChart#image) and [titleImage options](/vchart/option/storylineChart#titleImage) for the roles of image sources, layout dimensions and mark styles, including type definitions, layout-specific defaults and typed examples. + +## Node Dimensions and Lines + +Use `block.width` and `block.height` to adjust node dimensions in landscape and portrait layouts. Portrait also supports adaptive widths through `block.widthRatio`, `minWidth` and `maxWidth`; an explicit `width` takes priority. By default, landscape calculates widths from the node count and portrait allocates node slots from the available height. In the arc layout, `block.width` limits text width, which can still shrink to fit the available space. + +| Layout | Main line options | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `landscape` | Curve shown by default. Toggle with `line.visible`; style it with `line.style.stroke` and `line.style.lineWidth`. | +| `portrait` | Axis shown by default. Toggle with `line.visible`; use `line.style.fill` for its fill. Hiding the axis also hides its time markers. | +| `arc` | Main arc hidden by default. Set `line.visible: true` to show it. | +| `wing` | Wing path shown by default. Set `line.visible: false` to hide it. | +| `clock` | Built-in dashed orbit and lead lines use `themeColor`; the current layout ignores `line.visible` and `line.style`. | + +The interface also retains generic node and line options, including `block.showBackground`, `block.gap`, `image.position`, `image.gap`, `line.type`, `line.showArrow`, `line.arrowSize` and `line.distance`. The five specialized layouts currently do not use these options to control card backgrounds, spacing, image placement or arrows. Use the supported options described above. + +## Canvas Size and Content Length + +Start with the layout's default dimensions and spacing, then adjust images and text. Setting an explicit `height` helps the portrait layout reserve space for the final node's description. Clock layouts need sufficient horizontal space for text on both sides of the orbit. For more nodes, enlarge the canvas or shorten individual descriptions. + +The examples use fixed canvas dimensions to show the full layout and embedded SVG images so that no additional image downloads are needed. + +## Examples + +- [Landscape Storyline](/vchart/demo/extension-chart/storyline-landscape) +- [Portrait Storyline](/vchart/demo/extension-chart/storyline-portrait) +- [Clock Storyline](/vchart/demo/extension-chart/storyline-clock) +- [Arc Storyline](/vchart/demo/extension-chart/storyline-arc) +- [Wing Storyline](/vchart/demo/extension-chart/storyline-wing) diff --git a/docs/assets/guide/menu.json b/docs/assets/guide/menu.json index b9651e7a77..b3f28bfcc8 100644 --- a/docs/assets/guide/menu.json +++ b/docs/assets/guide/menu.json @@ -954,10 +954,17 @@ "zh": "时间线图组件", "en": "Timeline" } + }, + { + "path": "storyline", + "title": { + "zh": "叙事图", + "en": "Storyline" + } } ] } ] } ] -} \ No newline at end of file +} diff --git a/docs/assets/guide/zh/tutorial_docs/Chart_Extensions/storyline.md b/docs/assets/guide/zh/tutorial_docs/Chart_Extensions/storyline.md new file mode 100644 index 0000000000..cb892c7ab6 --- /dev/null +++ b/docs/assets/guide/zh/tutorial_docs/Chart_Extensions/storyline.md @@ -0,0 +1,180 @@ +# 扩展图表:叙事图 + +叙事图(Storyline Chart)将图片、标题和正文组合成一系列节点,适合展示产品演进、项目里程碑和故事发展过程。节点按照 `data` 数组的顺序排列,不会根据日期排序,也不使用时间比例尺计算间距。 + +Storyline 由 `@visactor/vchart-extension` 提供,支持 `landscape`、`portrait`、`clock`、`arc` 和 `wing` 五种布局。 + +完整字段、默认值和各布局的适用范围见[叙事图配置项](/vchart/option/storylineChart)。 + +## 注册与使用 + +安装与 `@visactor/vchart` 版本一致的 `@visactor/vchart-extension`,并在创建图表前注册: + +```js +import VChart from '@visactor/vchart'; +import { registerStorylineChart } from '@visactor/vchart-extension'; + +registerStorylineChart(); + +const spec = { + type: 'storyline', + width: 1000, + height: 560, + layout: 'landscape', + themeColor: '#3b82a0', + title: { style: { fontSize: 20 } }, + content: { style: { fontSize: 14, lineHeight: 21 } }, + data: [ + { id: 'discover', title: '发现需求', content: '访谈用户,明确需要解决的问题。' }, + { id: 'design', title: '设计原型', content: ['梳理关键流程。', '通过原型收集反馈。'] }, + { id: 'launch', title: '发布产品', content: '上线首个版本,持续改进体验。' } + ] +}; + +const vchart = new VChart(spec, { dom: 'chart' }); +vchart.renderSync(); +``` + +页面中需要提供 `id="chart"` 的容器。使用 TypeScript 时,可从扩展包导入 `IStorylineSpec` 为配置添加类型标注。 + +通过 CDN 引入时,使用扩展包暴露的全局对象,并将页面中的 VChart 构造函数传给注册函数: + +```html +
+ + + +``` + +生产环境建议为两个 CDN 地址指定相同的、包含 Storyline 的包版本。 + +## 数据结构 + +`data` 直接接收节点数组,无需使用普通图表中的 `{ id, values }` 数据集结构,也无需配置 `xField`、`yField` 或 `series`。 + +| 字段 | 类型 | 说明 | +| ---------- | ------------------------------------------------- | --------------------------------------------------------------- | +| `id` | `string \| number` | 可选的节点标识,省略时使用数据索引。 | +| `title` | `string` | 节点标题。 | +| `content` | `string \| string[]` | 节点正文;数组中的段落通过换行连接。 | +| `image` | `string \| HTMLImageElement \| HTMLCanvasElement` | 节点主图,可使用图片 URL、data URL 或图片、画布元素。 | +| `subImage` | 同 `image` | 纵向布局中绘制在主图后方的错位装饰图,省略时不绘制。 | +| `marker` | `string` | 仅纵向布局使用的时间标记,例如 `'2024'`,在中轴上逐字纵向排列。 | +| `datum` | `unknown` | 可选的原始业务数据。 | + +图片、标题和正文均可省略。需要时间顺序时,请先在业务侧排序,再传入 `data`。更新节点数组或布局时,使用包含新 `data` 的完整配置调用 `vchart.updateSpec(nextSpec)`。 + +## 布局 + +`layout` 可以是布局名称,也可以是包含 `type` 的对象。省略时使用 `landscape`。 + +完整类型定义、参数适用范围以及方向、角度、留白的配合方式,见 [layout 配置说明](/vchart/option/storylineChart#layout)。 + +| 布局 | 排列方式 | 适用场景 | +| ----------- | ----------------------------------------------------- | ---------------------- | +| `landscape` | 图片横向排列,文字在上下两侧交替出现,曲线串联节点。 | 横向流程、产品演进。 | +| `portrait` | 图片和文字沿中轴左右交替排列,可在轴上显示 `marker`。 | 纵向里程碑、发展历程。 | +| `clock` | 节点沿圆形轨道顺时针排列,文字向外展开。 | 阶段回顾、周期过程。 | +| `arc` | 节点沿弧形分布,主题图片作为视觉中心。 | 成长故事、主题信息图。 | +| `wing` | 圆形图片沿翼形主线排列,文字分布在两侧。 | 纵向叙事、项目推进。 | + +弧形布局可以通过 `direction` 切换穹顶和碗形: + +```js +layout: { + type: 'arc', + direction: 'up', // 'up':穹顶,主题图位于底部;'down':碗形,主题图位于顶部 + radiusRatio: 0.9 +} +``` + +翼形布局使用 `layout: { type: 'wing', direction: 'left' }`,其中 `direction` 可以是 `left`(默认)或 `right`。 + +当前专用布局的参数支持有所区别: + +- `arc` 支持 `radiusRatio`、`startAngle`、`endAngle`,角度单位为度。默认起止角为 `200` / `340`(`up`)或 `20` / `160`(`down`)。 +- `wing` 也支持上述三个参数。默认起止角为 `-70` / `70`(`left`)或 `110` / `250`(`right`),默认 `radiusRatio` 为 `0.92`。 +- `clock` 自动计算整圆节点的角度和半径,当前不使用上述三个参数。 +- `layout.padding` 支持数值或 `[top, right, bottom, left]`,用于横向、纵向节点布局的内部留白。图表外边距通过顶层 `padding` 设置。 + +## 图片与文字 + +`title` 配置的是每个节点的标题图元样式,标题文字来自 `data[].title`。正文来自 `data[].content`,样式通过 `content` 配置。这里的 `title` 不使用普通图表标题组件的 `text`、`subtext` 结构。 + +```js +title: { + style: { fontSize: 20, lineHeight: 27, fontWeight: 'bold', fill: '#23364d' } +}, +content: { + style: { fontSize: 14, lineHeight: 21, fill: '#526174' } +}, +image: { + showBackground: true, + style: { opacity: 0.95 } +} +``` + +| 配置 | 说明 | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------- | +| `themeColor` | 连接线、主轴和图片装饰的默认主题色,默认为 `#e8543d`。 | +| `title.style`、`content.style` | 节点文字样式;也可以通过各自的 `visible: false` 隐藏文字。标题最多展示两行,正文空间由布局决定。 | +| `image.width`、`image.height` | 纵向、弧形、翼形布局的图片尺寸配置;横向布局使用 `height` 并自动计算宽度,时钟布局自动计算节点图片尺寸。 | +| `image.showBackground` | 图片背景装饰,横向、纵向默认关闭,其他布局默认开启。不控制 `subImage` 的显隐。 | +| `image.style` | 图片图元样式,例如 `opacity`。 | +| `titleImage` | 主题图片,支持 `image`、`width`、`height`、`visible` 和 `style`;位置由布局决定。 | +| `marker` | 仅纵向布局生效,使用 `visible` 控制时间标记显隐,使用 `style.fontSize`、`style.lineHeight` 设置文字大小和行高。 | + +例如,为弧形布局添加主题图片: + +```js +titleImage: { + image: 'https://example.com/story-title.png', // 替换为自己的图片地址 + width: 320, + height: 140 +} +``` + +图片资源、布局尺寸与图元样式的分工,详见 [image 配置说明](/vchart/option/storylineChart#image)和 [titleImage 配置说明](/vchart/option/storylineChart#titleImage),其中包含类型定义、各布局的默认行为和带类型的配置示例。 + +## 节点尺寸与连线 + +横向、纵向布局可通过 `block.width`、`block.height` 调整节点尺寸。纵向布局还可以使用 `block.widthRatio`、`minWidth`、`maxWidth` 控制自适应宽度,显式 `width` 优先。横向布局默认按节点数量计算宽度,纵向布局默认根据可用高度分配节点槽位。弧形布局的 `block.width` 是文本宽度上限,空间不足时仍会收缩。 + +| 布局 | 主线配置 | +| ----------- | ------------------------------------------------------------------------------------------------------------ | +| `landscape` | 默认显示曲线,使用 `line.visible` 控制显隐,`line.style.stroke`、`line.style.lineWidth` 设置样式。 | +| `portrait` | 默认显示中轴,使用 `line.visible` 控制显隐,`line.style.fill` 设置轴的填充。隐藏中轴也会隐藏其中的时间标记。 | +| `arc` | 弧形主线默认隐藏,设置 `line.visible: true` 后显示。 | +| `wing` | 翼形主线默认显示,可通过 `line.visible: false` 隐藏。 | +| `clock` | 使用布局内置的虚线轨道和引线,颜色取自 `themeColor`,当前不使用 `line.visible` 和 `line.style`。 | + +接口中还保留了通用节点和连线参数,如 `block.showBackground`、`block.gap`、`image.position`、`image.gap`、`line.type`、`line.showArrow`、`line.arrowSize`、`line.distance`。当前五种专用布局并不使用这些参数控制对应的卡片背景、间距、图片位置或箭头,请优先使用上述已支持的配置。 + +## 画布与内容长度 + +建议先使用布局的默认尺寸和留白,再调整图片及文字。显式设置 `height` 有助于纵向布局为最后一个节点的正文预留空间。时钟布局需要足够的横向空间容纳轨道两侧的文字;节点较多时,应增大画布或减少单个节点的文字量。 + +示例使用固定画布尺寸,便于展示完整布局,并使用内嵌 SVG 图片,无需额外下载图片素材。 + +## 示例 + +- [横向叙事图](/vchart/demo/extension-chart/storyline-landscape) +- [纵向叙事图](/vchart/demo/extension-chart/storyline-portrait) +- [时钟叙事图](/vchart/demo/extension-chart/storyline-clock) +- [弧形叙事图](/vchart/demo/extension-chart/storyline-arc) +- [翼形叙事图](/vchart/demo/extension-chart/storyline-wing) diff --git a/docs/assets/option/en/chart/storyline.md b/docs/assets/option/en/chart/storyline.md new file mode 100644 index 0000000000..716e15bec7 --- /dev/null +++ b/docs/assets/option/en/chart/storyline.md @@ -0,0 +1,682 @@ +{{ target: chart-storyline }} + +# storylineChart + +A Storyline chart presents an ordered sequence of nodes with images, titles and descriptions. It supports landscape, portrait, clock, arc and wing layouts. + +Install the extension package with the same version as `@visactor/vchart` and register the chart before use: + +```ts +import VChart from '@visactor/vchart'; +import { registerStorylineChart } from '@visactor/vchart-extension'; + +registerStorylineChart(); +const vchart = new VChart( + { + type: 'storyline', + width: 1000, + height: 560, + layout: 'landscape', + data: [ + { id: 'discover', title: 'Discover', content: 'Talk to users and define the goal.' }, + { id: 'launch', title: 'Launch', content: 'Release the product and collect feedback.' } + ] + }, + { dom: 'chart' } +); +vchart.renderSync(); +``` + +Pass the node array directly to `data`. The top-level `title` configures node title marks. No `series`, `xField`, `yField` or axes are required. + +See the [Storyline guide](/vchart/guide/tutorial_docs/Chart_Extensions/storyline) for usage. Examples: [landscape](/vchart/demo/extension-chart/storyline-landscape), [portrait](/vchart/demo/extension-chart/storyline-portrait), [clock](/vchart/demo/extension-chart/storyline-clock), [arc](/vchart/demo/extension-chart/storyline-arc) and [wing](/vchart/demo/extension-chart/storyline-wing). + +## type(string) = 'storyline' + +Required chart type. Must be `'storyline'`. Call `registerStorylineChart()` before use. The supported layouts are `landscape`, `portrait`, `clock`, `arc` and `wing`. See the [Storyline guide](/vchart/guide/tutorial_docs/Chart_Extensions/storyline) for configuration examples. + +## width(number) + +Chart width in pixels. The clock layout needs enough width for text on both sides of the orbit. + +## height(number) + +Chart height in pixels. The portrait layout divides the available height into node slots. An explicit height helps reserve enough space for the last node's description. + +## autoFit(boolean) = true + +Whether to fit the container size. Explicit `width` and `height` take precedence over container dimensions. + +## background(string|Object) + +Chart background. + +{{ use: background }} + +## padding(number|Array|Object) + +Outer chart padding in pixels. Accepts a single number, `[top, right, bottom, left]` or `{ top, right, bottom, left }`. + +When unset, the base defaults are: + +| Layout | Default padding (top, right, bottom, left) | +| ---------------- | --------------------------------------------------------- | +| `landscape` | `[20, 20, 100, 20]` | +| `portrait` | `[20, 20, automatic, 20]`, with a bottom minimum of `100` | +| `clock` | `[40, 40, 60, 40]` | +| `arc` / `up` | `[280, h, 100, h]` | +| `arc` / `down` | `[0, h, 280, h]` | +| `wing` / `left` | `[40, 20, 100, 20]` | +| `wing` / `right` | `[100, 20, 40, 20]` | + +Here `h = Math.round(width / (Math.max(data.length, 1) + 1))`, or `20` when chart width is unset. Portrait calculates bottom padding from canvas height, node count, image height and title line height. + +With a visible `titleImage`, landscape, portrait, clock and wing use the larger of the configured top padding and the image's required space. Arc handles its theme image through its own geometry. + +### top(number) + +Top padding. With a visible theme image, landscape, portrait, clock and wing ensure that top padding is at least the space reserved for the image. + +### right(number) + +Right padding. + +### bottom(number) + +Bottom padding. + +### left(number) + +Left padding. + +## data(Array) + +Required node array. Nodes follow array order; dates are not sorted automatically, and spacing does not use a time scale. Do not wrap the array in the `{ id, values }` dataset format used by standard charts. + +```ts +data: [ + { + id: 'launch', + title: 'Launch', + content: ['Release the first version.', 'Collect user feedback.'], + marker: '2024', + image: 'https://example.com/launch.png' // Replace with your own image URL + } +]; +``` + +### id(string|number) + +Optional unique node identifier. Defaults to the data index when omitted. + +### title(string) + +Node title. Configure its text style through the top-level `title.style`. An omitted or empty string creates no title mark. + +### content(string|Array) + +Node description as a string or an array of strings. Array entries are joined with line breaks. An omitted value or an empty array creates no description mark. + +### image(string|HTMLImageElement|HTMLCanvasElement) + +Main node image. Accepts an image URL, data URL, image element or canvas element. When omitted, landscape and portrait create no main image; clock, arc and wing draw a placeholder shape. Placeholder shapes are independent of `image.visible` and `image.showBackground`. + +### subImage(string|HTMLImageElement|HTMLCanvasElement) + +Offset decorative image behind the main image in the portrait layout. It is omitted when unset and is independent of `image.showBackground`. The other four layouts do not use this field. + +### marker(string) + +Time label used only in the portrait layout, such as `'2024'`. Characters are arranged vertically on the central axis. Configure size and visibility through the top-level `marker`. + +### datum(any) + +Original business data retained on the node. It does not affect layout or generate text. + +## layout(string|Object) = 'landscape' + +Corresponds to `IStorylineSpec.layout`, typed as `StorylineLayoutType | IStorylineLayoutOptions`. Defaults to `'landscape'`. A string selects the layout; an object additionally configures direction, angles, radius or internal padding. The object's `type` is required and its other fields are optional. + +Type definitions from the extension's `src/charts/storyline/interface.ts`: + +```ts +export type StorylineLayoutType = 'clock' | 'arc' | 'wing' | 'landscape' | 'portrait'; +export type StorylineWingDirection = 'left' | 'right'; +export type StorylineArcDirection = 'up' | 'down'; +export interface IStorylineLayoutOptions { + type: StorylineLayoutType; + padding?: number | [number, number, number, number]; + radiusRatio?: number; + startAngle?: number; + endAngle?: number; + direction?: StorylineWingDirection | StorylineArcDirection; +} +``` + +Parameters used by the five layouts: + +| type | Node arrangement | Effective layout parameters | +| ----------- | -------------------------------------------------------------- | ---------------------------------------------------- | +| `landscape` | Horizontal images with text alternating above and below | `padding` | +| `portrait` | Alternating sides of a central axis, with optional time labels | `padding` | +| `clock` | Equal angular spacing clockwise in array order | Automatic geometry; other layout fields are unused | +| `arc` | Dome or bowl arc | `direction`, `radiusRatio`, `startAngle`, `endAngle` | +| `wing` | Wing arc anchored on the left or right | `direction`, `radiusRatio`, `startAngle`, `endAngle` | + +```ts +import type { IStorylineSpec } from '@visactor/vchart-extension'; + +const simpleLayout: IStorylineSpec['layout'] = 'portrait'; +const arcLayout: IStorylineSpec['layout'] = { + type: 'arc', + direction: 'down', + radiusRatio: 0.88, + startAngle: 20, + endAngle: 160 +}; +``` + +The `direction` type combines four directions, but valid values depend on the layout: `up/down` for arc and `left/right` for wing. The table describes the fields actually used at runtime. + +### type(string) + +Required when using an object. Options are `'landscape'`, `'portrait'`, `'clock'`, `'arc'` and `'wing'`. + +### padding(number|Array) + +Typed as `number | [number, number, number, number]`, in pixels. A number applies to all four sides; a tuple must use `[top, right, bottom, left]`. Landscape and portrait use it to calculate the node area. + +Precedence is `layout.padding` → `block.padding` → `24`; an explicit `0` is preserved. It applies inside the region established by the top-level `padding`. Both padding layers participate independently rather than replacing each other. The specialized clock, arc and wing geometry ignores this field. + +### radiusRatio(number) + +Radius ratio for arc and wing. Arc defaults to `0.88` and applies it to the horizontal radius; its vertical radius is derived from theme image height and the start angle. Wing defaults to `0.92` and applies it to both radii. Clock calculates its orbit radius automatically and ignores this parameter. + +This is a unitless multiplier: `0.88` multiplies the layout's base radius by `0.88`; it does not specify image width. The type only requires a `number`, and the implementation does not apply a uniform `[0, 1]` clamp. Larger ratios can move content beyond the available area. + +### startAngle(number) + +Start angle for arc or wing, in degrees. + +- `arc`: defaults to `200` for `up` and `20` for `down`. +- `wing`: defaults to `-70` for `left` and `110` for `right`. + +The clock layout currently does not use this parameter. + +Angles use screen coordinates: `0°` points right, `90°` down, `180°` left and `270°` up. Arc and wing arrange nodes using the difference between the start and end angles. + +### endAngle(number) + +End angle for arc or wing, in degrees. + +- `arc`: defaults to `340` for `up` and `160` for `down`. +- `wing`: defaults to `70` for `left` and `250` for `right`. + +The clock layout currently does not use this parameter. + +Together with `startAngle`, this determines the arc covered by the nodes. Two or more nodes interpolate across the angle interval in array order; one node uses the midpoint angle. + +### direction(string) + +Direction for arc or wing. Other layouts do not use this parameter. + +- `arc`: `'up'` (default) creates a dome with the title image at the bottom; `'down'` creates a bowl with the title image at the top. +- `wing`: `'left'` (default) or `'right'`, controlling the side where the wing's circle center is anchored. + +## themeColor(string) = '#e8543d' + +Default theme color for axes, connecting lines, image backgrounds and accents. Explicit mark styles take precedence. + +## block(Object) + +Node dimensions and internal padding. Landscape and portrait use node boxes; arc uses width options for horizontal geometry and text width. Clock and wing use their own geometry. + +The interface fields `block.gap` and `block.showBackground` belong to the generic fallback layout and are unused by the five public layouts. Use `image.showBackground` for image decorations. + +### width(number) + +Fixed node width for landscape and portrait, taking precedence over adaptive width options. Arc also uses it as a text width limit and may shrink text areas when space is limited. Clock and wing calculate node geometry automatically. + +### widthRatio(number) = 0.24 + +Adaptive node width as a fraction of the available view width, constrained by `minWidth` and `maxWidth`. Used by portrait and by arc's horizontal geometry calculation. Landscape calculates width from the node count when `width` is unset. + +### minWidth(number) = 180 + +Minimum adaptive node width. Ignored when `width` is set. Applies to the same layouts as `widthRatio`. + +### maxWidth(number) + +Maximum adaptive node width, defaulting to `Math.max(minWidth, 320)`. Ignored when `width` is set. Applies to the same layouts as `widthRatio`. + +### height(number) + +Base node height for landscape and portrait. Landscape defaults to `320`. When unset, portrait uses `Math.max(120, Math.floor(innerAvailableHeight / (data.length + 1)))`; image height defaults to `0.6` times that value, and the description area is at least `1.25` times it or three description lines. + +Clock, arc and wing do not use this parameter to size their nodes. + +### padding(number|Array) + +Internal padding as a single number or `[top, right, bottom, left]`. For landscape and portrait node positions it is the fallback for `layout.padding`, defaulting to `24`. Clock also uses it to calculate its orbit and text areas, defaulting to `24`. + +Landscape additionally uses it to calculate image width and minimum image height; the local fallback used in the minimum-height calculation is `12`. Arc and wing do not use it in their specialized node geometry. + +### style(Object) + +Rectangle background style. Landscape and portrait merge this into the image background when `image.showBackground` is enabled. For example, `{ fill: '#fff', stroke: '#3b82a0', lineWidth: 2 }`. + +{{ use: graphic-rect(prefix = '###') }} + +## title(Object) + +Title mark configuration for each node, using `data[].title` and displaying at most two lines by default. This is not the standard chart title component; it does not use the top-level `title.text` or `title.subtext`. + +### visible(boolean) = true + +Whether to display node titles. + +### style(Object) + +Title text style, for example `{ fontSize: 20, lineHeight: 27, fontWeight: 'bold', fill: '#23364d' }`. Use numbers for `fontSize` and `lineHeight`. + +Without an explicit font size, adaptive ranges are `8–34` for landscape and portrait, `8–30` for clock and `10–40` for arc. Wing uses text measurements to fit within `14–30`. An explicit `fontSize` takes precedence. Default line height is the rounded font size multiplied by `1.35` for landscape, portrait and arc, `1.28` for clock, or `1.3` for wing. + +Titles default to bold with a white stroke. Landscape, portrait and arc use a dark fill; clock and wing use the theme color. The layout sets position, alignment and a default two-line limit; explicit styles override mark attributes. + +{{ use: storyline-text-style(prefix = '###') }} + +## content(Object) + +Description mark configuration, using `data[].content`. The current five layouts join paragraphs into text with line breaks; available width and height depend on the layout. + +### visible(boolean) = true + +Whether to display descriptions. + +### style(Object) + +Description text style, for example `{ fontSize: 14, lineHeight: 21, fill: '#526174' }`. Use numbers for font size and line height. + +Default font size / line height: `16 / 23` for landscape and portrait, `14 / 20` for clock and `12 / 17` for wing. Arc scales from a baseline of `18 / 24` according to node count and available space. Clock's description height defaults to a fixed `80`; changing line height does not enlarge that area. + +Landscape, portrait and arc use `heightLimit` and an ellipsis for overflowing descriptions by default. Wing allows long descriptions to extend and potentially exceed the canvas. Set `heightLimit` and `ellipsis` to adjust text display; this does not automatically increase node spacing. + +{{ use: storyline-text-style(prefix = '###') }} + +## image(Object) + +Corresponds to `IStorylineSpec.image`, typed as `IStorylineImageSpec`, and configures all main node images. Individual sources belong in `data[i].image`; the main-image configuration has no top-level `image` source field. + +```ts +import type { IMarkSpec, IImageMarkSpec } from '@visactor/vchart'; + +export type StorylineImagePosition = 'top' | 'left' | 'right' | 'bottom'; +export interface IStorylineImageSpec extends IMarkSpec { + width?: number; + height?: number; + position?: StorylineImagePosition; + gap?: number; + showBackground?: boolean; +} +``` + +This interface extends `IMarkSpec`, inheriting `visible`, `interactive`, `zIndex`, `style`, `state` and other common mark options. The exact `style` type is `ConvertToMarkStyleSpec`. Style attributes accept static values and mark style expressions; the top-level `width` and `height` used by Storyline layout are numeric pixel values. + +Responsibilities of each configuration layer: + +| Configuration | Purpose | +| ---------------------- | ---------------------------------------------------------------------------------------- | +| `data[i].image` | Image URL, data URL, image element or canvas element for node i | +| `image.width/height` | Inputs to the corresponding layout's image-size calculation | +| `image.visible` | Visibility of main image marks that have been created | +| `image.showBackground` | Decorations created separately by the layout | +| `image.style` | Main-image drawing attributes, merged after layout defaults | +| `data[i].subImage` | Independent offset decoration in portrait; does not inherit `image` styles or visibility | + +The type still declares `position` (`top/left/right/bottom`) and `gap` (spacing in pixels) for generic fallback nodes. The five public layouts arrange images and text themselves and ignore these two fields. + +This portrait example sets both layout dimensions and the image fitting mode: + +```ts +import type { IStorylineSpec } from '@visactor/vchart-extension'; + +const spec: IStorylineSpec = { + type: 'storyline', + width: 1080, + height: 1920, + layout: 'portrait', + image: { + width: 220, + height: 140, + visible: true, + showBackground: true, + style: { imageMode: 'cover', imagePosition: 'center', cornerRadius: 12 } + }, + data: [ + { + title: '1930', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-2022.png' + } + ] +}; +``` + +Omitting the entire `image` configuration still draws main images with defaults when nodes provide `data[i].image`. See `data.image` for placeholders used when a source is missing. + +### visible(boolean) = true + +Whether to display image marks created from `data[].image`. Does not control decorations, placeholders for missing images or `data[].subImage`, and does not remove the space allocated to the image. + +### interactive(boolean) = false + +Inherited from `IMarkSpec`. Whether main image marks participate in interaction. Set to `true` for mouse events. Does not enable interaction on separate decorations or `subImage`. + +### zIndex(number) + +Inherited from `IMarkSpec`. Overrides the image mark's drawing order; defaults depend on the layout. Marks inside a node remain subject to their parent group's drawing order. + +### state(Object) + +State styles inherited from `IMarkSpec`. Supports `normal`, `hover`, `hover_reverse`, `selected`, `selected_reverse` and custom state names. Each value can be an image style directly or a full state configuration containing `style`. + +Also enable `image.interactive` for mouse-triggered states. State styles do not change Storyline node layout. + +### width(number) + +Portrait defaults to `Math.max(blockWidth, 80)`. Wing uses a default base width of `160` and further scales it along the main line. + +Arc uses `Math.max(image.width ?? 240, image.height ?? 240)` as its base diameter, then scales to the available space as a square image area. Setting only one dimension below `240` therefore does not reduce the base diameter. + +Landscape and clock calculate image width automatically and ignore this field. + +### height(number) + +Landscape starts from `0.42` times node height and adjusts for title line height, internal padding and canvas space. Portrait defaults to `Math.round(blockHeight * 0.6)`. Wing uses a default base height of `160` and further scales it along the main line. + +Arc combines this with `image.width` to determine its base diameter; see `image.width`. Clock calculates image height automatically and ignores this field. + +### showBackground(boolean) + +Whether to display image background decorations. Defaults to `false` for landscape and portrait, and `true` for clock, arc and wing. + +Does not control main images, `subImage` or placeholders for missing main images. Use `image.visible` to hide main images. + +### style(Object) + +The type comes from `IMarkSpec['style']`. The layout first calculates default position, dimensions and source, then merges this object so explicit styles take precedence. + +| style field | Default / purpose | +| ----------------------------- | ------------------------------------------------------------------------------------------------------- | +| `imageMode` | `contain`: fit the whole image; also `cover` (fill and crop), `fill` (stretch), `auto` (intrinsic size) | +| `imagePosition` | `center`; also keywords, combined positions and `[x, y]` tuples such as `['25%', '75%']` | +| `repeatX / repeatY` | `no-repeat`; also `repeat` and `stretch` | +| `imageScale` | Extra content scaling without changing layout dimensions | +| `imageOffsetX / imageOffsetY` | Pixel offsets for image content without moving the node | +| `opacity` | Overall mark opacity in `0–1` | + +`imageMode`, `imagePosition`, `imageScale` and image-content offsets are for non-repeating rendering; keep both `repeatX` and `repeatY` at `no-repeat` when using them. + +Changing `image.width/height` participates in supported layout calculations. Changing `image.style.width/height` only overrides the main-image drawing box; decorations, leader lines and text keep their original layout. Likewise, `image.style.image` only overrides an existing main image and cannot replace `data[i].image` to trigger its creation. + +{{ use: graphic-image(prefix = '###') }} + +#### cornerRadius(number|Array) + +Landscape and portrait main images have no additional corner-radius override. Clock, arc and wing default to a radius-based circular clip. Accepts one number or four values in `[top-left, top-right, bottom-right, bottom-left]` order, in pixels. Affects the image mark only; decorations use separate geometry. + +## titleImage(Object) + +Corresponds to `IStorylineSpec.titleImage`, typed as `IStorylineTitleImageSpec`. This is one theme image shared by the chart, with its source in `titleImage.image`. Configure it separately from main node images in `data[i].image` and node decorations in `data[i].subImage`. + +```ts +import type { IMarkSpec, IImageMarkSpec } from '@visactor/vchart'; + +export interface IStorylineTitleImageSpec extends IMarkSpec { + width?: number; + height?: number; + visible?: boolean; + image?: string | HTMLImageElement | HTMLCanvasElement; +} +``` + +All four declared fields, `image`, `visible`, `width` and `height`, are optional, but a valid `image` is necessary to create the theme image. This interface extends `IMarkSpec`, inheriting `visible`, `interactive`, `zIndex`, `style`, `state` and other common mark options. The exact `style` type is `ConvertToMarkStyleSpec`. Style attributes accept static values and mark style expressions; the top-level `width` and `height` used by Storyline layout are numeric pixel values. + +| Layout | Default theme-image placement | Area used for default dimensions | +| ------------------------------ | --------------------------------------------------- | -------------------------------- | +| `landscape / portrait / clock` | Centered at the canvas top, with a `12` pixel inset | Entire canvas | +| `arc / up` | Bottom center of the plotting region | Region after chart padding | +| `arc / down` | Top center of the plotting region | Region after chart padding | +| `wing / left` | Canvas top right, with a `12` pixel top inset | Entire canvas | +| `wing / right` | Canvas top left, with a `12` pixel top inset | Entire canvas | + +Clock uses the top theme image. The top-level `width` and `height` specify the layout box in pixels; their defaults are documented below. `style.imageMode` fits the image content inside that box; the source's intrinsic dimensions do not determine the box. + +```ts +import type { IStorylineSpec } from '@visactor/vchart-extension'; + +const titleImage: IStorylineSpec['titleImage'] = { + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/title-world-cap.png', + visible: true, + width: 520, + height: 180, + style: { imageMode: 'contain', imagePosition: 'center', opacity: 0.9 } +}; +``` + +### image(string|HTMLImageElement|HTMLCanvasElement) + +The source type is `string | HTMLImageElement | HTMLCanvasElement`: an image URL, data URL, loaded image element or canvas element. + +When unset, no theme image is created. Creation checks this field, so setting only `titleImage.style.image` cannot create the image. Once the mark exists, `style.image` can override its drawing source; normally keep the source in this field. + +### visible(boolean) = true + +Whether to create the theme image, defaulting to `true`. A source in `titleImage.image` is still required. Setting this to `false` removes the additional top reserve in landscape, portrait, clock and wing, while retaining each layout's base padding. + +Arc always uses the theme-image box dimensions, including defaults, to calculate its radius and position. Hiding the image does not collapse that geometry. + +`titleImage.style.visible: false` only hides a mark after creation and keeps the top reserve. Prefer this top-level field to disable the theme image. + +### interactive(boolean) = false + +Inherited from `IMarkSpec`. Whether theme image marks participate in interaction. Set to `true` for mouse events. Does not enable interaction on separate decorations or `subImage`. + +### zIndex(number) + +Inherited from `IMarkSpec`. Overrides the image mark's drawing order; defaults depend on the layout. Marks inside a node remain subject to their parent group's drawing order. + +### state(Object) + +State styles inherited from `IMarkSpec`. Supports `normal`, `hover`, `hover_reverse`, `selected`, `selected_reverse` and custom state names. Each value can be an image style directly or a full state configuration containing `style`. + +Also enable `titleImage.interactive` for mouse-triggered states. State styles do not change Storyline node layout. + +### width(number) + +Theme image width. Landscape, portrait and clock default to `0.52` times canvas width, capped at `720`. Wing defaults to `0.6` times canvas width, capped at `820`. Arc defaults to `0.68` times region width, capped at `900`. + +An explicit width overrides these defaults. Arc enforces a minimum width of `80`; other layouts enforce a minimum of `1`. + +### height(number) + +Theme image height. Landscape, portrait, clock and wing default to `0.36` times theme image width, capped by canvas height. Arc defaults to `0.34` times image width. + +An explicit height overrides these defaults. Arc enforces a minimum height of `40`; other layouts enforce a minimum of `1`. + +### style(Object) + +The type comes from `IMarkSpec['style']`. The layout first calculates default position, dimensions and source, then merges this object so explicit styles take precedence. + +| style field | Default / purpose | +| ----------------------------- | ------------------------------------------------------------------------------------------------------- | +| `imageMode` | `contain`: fit the whole image; also `cover` (fill and crop), `fill` (stretch), `auto` (intrinsic size) | +| `imagePosition` | `center`; also keywords, combined positions and `[x, y]` tuples such as `['25%', '75%']` | +| `repeatX / repeatY` | `no-repeat`; also `repeat` and `stretch` | +| `imageScale` | Extra content scaling without changing layout dimensions | +| `imageOffsetX / imageOffsetY` | Pixel offsets for image content without moving the node | +| `opacity` | Overall mark opacity in `0–1` | + +`imageMode`, `imagePosition`, `imageScale` and image-content offsets are for non-repeating rendering; keep both `repeatX` and `repeatY` at `no-repeat` when using them. + +Changing `titleImage.width/height` participates in theme-image layout and space reservation. Changing `titleImage.style.width/height`, `x/y` or `dx/dy` only overrides drawing attributes; it does not recalculate top padding or arc geometry. + +{{ use: graphic-image(prefix = '###') }} + +#### cornerRadius(number|Array) + +Theme images have no additional corner-radius override by default. Accepts one number or four values in `[top-left, top-right, bottom-right, bottom-left]` order, in pixels. Affects the image mark only; decorations use separate geometry. + +## line(Object) + +Main line or central axis configuration. Landscape, portrait, arc and wing generate their own curve, axis, arc and wing shape; clock uses a fixed orbit and leader lines. + +The interface fields `line.type`, `line.showArrow`, `line.arrowSize` and `line.distance` belong to generic fallback connections and do not affect the main lines of the five public layouts. + +### visible(boolean) + +- `landscape`: visible by default; draws the main curve and node dots when there are at least two nodes. Set to `false` to hide both while retaining each node's text leader line. +- `portrait`: visible by default; draws the central axis when there are at least two nodes. Set to `false` to hide the axis and its time labels. +- `arc`: hidden by default; explicitly set to `true` to draw the main arc. Text leader lines remain independent. +- `wing`: visible by default; set to `false` to hide the main wing shape. Text leader lines remain independent. +- `clock`: ignores this parameter and draws its orbit and leader lines according to its built-in layout. + +### style(Object) + +The landscape curve supports `stroke`, `lineWidth` and `lineDash`. The portrait axis uses `fill`, `stroke`, `lineWidth` and `cornerRadius`. Wing uses `fill` or `stroke` as the main shape's fill color. Arc and clock ignore this configuration and use `themeColor` for their line colors. + +Line geometry is generated by the layout, not configured through `style.path` or `style.points`. + +#### stroke(string|Object|boolean) + +Stroke of the landscape curve or portrait axis. Wing also uses `stroke` as its fill color when `fill` is unset. + +#### fill(string|Object) + +Fill color of the portrait axis or wing shape. Portrait defaults to a theme color gradient; wing defaults to the theme color. + +#### lineWidth(number) + +Width of the landscape curve (default `4`) or portrait axis stroke (default `0`), in pixels. Wing also uses this as a fallback for the width at the end of its main shape. + +#### lineDash(Array) + +Dash pattern of the landscape curve, defaulting to `[6, 5]`. Also applies to the leader lines connecting landscape nodes to their text. + +#### cornerRadius(number|Array) + +Corner radius of the portrait axis, defaulting to `0`. + +## marker(Object) + +Time label configuration for portrait, using `data[].marker`. Characters are arranged vertically on the central axis. Other layouts do not use this configuration. + +### visible(boolean) = true + +Whether to display time labels. Labels are also omitted when the axis is hidden or the node has no `marker` value. + +### style(Object) + +Time label style. Common options are numeric `fontSize` and `lineHeight`; when omitted, they adapt to the canvas, label length and slot space. The default character color is white, with per-character rich text styles generated by the layout. + +#### fontSize(number) + +Time label font size in pixels. When unset, it adapts to the canvas, text length and axis slot, within a default range of `16–38`. + +#### lineHeight(number) + +Line height for each vertically arranged character, defaulting to `Math.round(resolvedFontSize * 0.9)`. + +#### fontWeight(string|number) = 'bold' + +Time label font weight. + +#### shadowColor(string) = 'rgba(0, 0, 0, 0.3)' + +Text shadow color. + +#### shadowBlur(number) = 8 + +Text shadow blur radius. + +#### shadowOffsetX(number) = 0 + +Horizontal text shadow offset. + +#### shadowOffsetY(number) = 5 + +Vertical text shadow offset. + +{{ target: storyline-text-style }} + +#${prefix} fontSize(number) + +Font size in pixels. + +#${prefix} lineHeight(number) + +Line height in pixels. + +#${prefix} fontFamily(string) + +Font family. + +#${prefix} fontWeight(string|number) + +Font weight, such as `'normal'`, `'bold'` or `500`. + +#${prefix} fill(string|Object) + +Text fill color. + +#${prefix} stroke(string|Object|boolean) + +Text stroke color. Set to `false` to disable the stroke. + +#${prefix} lineWidth(number) + +Text stroke width in pixels. + +#${prefix} textAlign(string) + +Horizontal alignment, such as `'left'`, `'center'` or `'right'`. Defaults depend on the layout. + +#${prefix} textBaseline(string) + +Vertical alignment baseline, such as `'top'`, `'middle'` or `'bottom'`. Defaults depend on the layout. + +#${prefix} maxLineWidth(number) + +Maximum line width. Leave enough room for adjacent nodes when overriding the width calculated by the layout. + +#${prefix} opacity(number) + +Text opacity from `0` to `1`. + +#${prefix} dx(number) + +Horizontal offset from the layout position, in pixels. + +#${prefix} dy(number) + +Vertical offset from the layout position, in pixels. + +#${prefix} heightLimit(number) + +Maximum text rendering height in pixels. Affects text display, not node spacing. + +#${prefix} lineClamp(number) + +Maximum text lines. Titles default to `2`; adjust the text height when changing this limit. + +#${prefix} ellipsis(string|boolean) + +Overflow marker, such as `'...'`. + +#${prefix} whiteSpace(string) + +Text wrapping mode, defaulting to `'normal'` in these layouts. + +#${prefix} wordBreak(string) + +Word breaking mode, defaulting to `'break-word'` in these layouts. diff --git a/docs/assets/option/en/option.md b/docs/assets/option/en/option.md index deb0f87587..d0d459c5ce 100644 --- a/docs/assets/option/en/option.md +++ b/docs/assets/option/en/option.md @@ -67,3 +67,5 @@ {{import: chart-common}} {{import: chart-image-cloud}} + +{{import: chart-storyline}} diff --git a/docs/assets/option/zh/chart/storyline.md b/docs/assets/option/zh/chart/storyline.md new file mode 100644 index 0000000000..29a928e2b0 --- /dev/null +++ b/docs/assets/option/zh/chart/storyline.md @@ -0,0 +1,682 @@ +{{ target: chart-storyline }} + +# storylineChart + +叙事图,使用图片、标题和正文展示一系列有顺序的节点,支持 `landscape`(横向布局)、`portrait`(纵向布局)、`clock`(时钟布局)、`arc`(弧形布局)和 `wing`(翼形布局)。 + +使用前需要安装与 `@visactor/vchart` 版本一致的扩展包并注册图表: + +```ts +import VChart from '@visactor/vchart'; +import { registerStorylineChart } from '@visactor/vchart-extension'; + +registerStorylineChart(); +const vchart = new VChart( + { + type: 'storyline', + width: 1000, + height: 560, + layout: 'landscape', + data: [ + { id: 'discover', title: '发现需求', content: '访谈用户,明确目标。' }, + { id: 'launch', title: '发布产品', content: '上线产品,收集反馈。' } + ] + }, + { dom: 'chart' } +); +vchart.renderSync(); +``` + +`data` 直接传入节点数组,`title` 表示节点标题图元配置。无需配置 `series`、`xField`、`yField` 或坐标轴。 + +详细用法见[叙事图教程](/vchart/guide/tutorial_docs/Chart_Extensions/storyline)。示例:[`landscape`(横向布局)](/vchart/demo/extension-chart/storyline-landscape)、[`portrait`(纵向布局)](/vchart/demo/extension-chart/storyline-portrait)、[`clock`(时钟布局)](/vchart/demo/extension-chart/storyline-clock)、[`arc`(弧形布局)](/vchart/demo/extension-chart/storyline-arc)、[`wing`(翼形布局)](/vchart/demo/extension-chart/storyline-wing)。 + +## type(string) = 'storyline' + +图表类型,必填,固定为 `'storyline'`。使用前调用 `registerStorylineChart()`。当前支持的布局为 `landscape`(横向布局)、`portrait`(纵向布局)、`clock`(时钟布局)、`arc`(弧形布局)、`wing`(翼形布局),配置示例见[叙事图教程](/vchart/guide/tutorial_docs/Chart_Extensions/storyline)。 + +## width(number) + +图表宽度,单位为像素。`clock`(时钟布局)需要为轨道两侧的文字预留足够宽度。 + +## height(number) + +图表高度,单位为像素。`portrait`(纵向布局)会根据可用高度分配节点槽位,显式配置有助于为最后一个节点的正文预留空间。 + +## autoFit(boolean) = true + +是否自适应容器尺寸。显式配置的 `width`、`height` 优先于容器尺寸。 + +## background(string|Object) + +图表背景。 + +{{ use: background }} + +## padding(number|Array|Object) + +图表外边距,单位为像素。支持统一数值、`[top, right, bottom, left]` 或 `{ top, right, bottom, left }`。 + +未配置时,各布局的基础默认值如下: + +| 布局 | 默认外边距(上、右、下、左) | +| ---------------------------- | ---------------------------------------- | +| `landscape`(横向布局) | `[20, 20, 100, 20]` | +| `portrait`(纵向布局) | `[20, 20, 自动计算, 20]`,底部至少 `100` | +| `clock`(时钟布局) | `[40, 40, 60, 40]` | +| `arc`(弧形布局) / `up` | `[280, h, 100, h]` | +| `arc`(弧形布局) / `down` | `[0, h, 280, h]` | +| `wing`(翼形布局) / `left` | `[40, 20, 100, 20]` | +| `wing`(翼形布局) / `right` | `[100, 20, 40, 20]` | + +其中 `h = Math.round(width / (Math.max(data.length, 1) + 1))`;未指定图表宽度时 `h = 20`。`portrait`(纵向布局)的底部留白根据画布高度、节点数量、图片高度和标题行高计算。 + +`landscape`(横向布局)、`portrait`(纵向布局)、`clock`(时钟布局)和 `wing`(翼形布局)配置可见的 `titleImage` 时,顶部留白还会取图片所需空间与配置值中的较大值。`arc`(弧形布局)通过自己的主题图几何安排空间。 + +### top(number) + +顶部留白。`landscape`(横向布局)、`portrait`(纵向布局)、`clock`(时钟布局)和 `wing`(翼形布局)配置可见的主题图片时,会保证顶部留白不小于图片预留空间。 + +### right(number) + +右侧留白。 + +### bottom(number) + +底部留白。 + +### left(number) + +左侧留白。 + +## data(Array) + +必填的节点数组,按照数组顺序排列,不会自动根据时间排序,也不通过时间比例尺计算间距。不要使用普通图表的 `{ id, values }` 数据集包装。 + +```ts +data: [ + { + id: 'launch', + title: '发布产品', + content: ['上线首个版本。', '收集使用反馈。'], + marker: '2024', + image: 'https://example.com/launch.png' // 替换为自己的图片地址 + } +]; +``` + +### id(string|number) + +可选的节点唯一标识,省略时使用数据索引。 + +### title(string) + +节点标题。文字样式通过顶层 `title.style` 配置,省略或传入空字符串时不创建标题。 + +### content(string|Array) + +节点正文,可以是字符串或字符串数组。数组中的段落通过换行连接。省略或传入空数组时不创建正文。 + +### image(string|HTMLImageElement|HTMLCanvasElement) + +节点主图,支持图片 URL、data URL、图片元素或画布元素。省略时,`landscape`(横向布局)、`portrait`(纵向布局)不创建主图;`clock`(时钟布局)、`arc`(弧形布局)和 `wing`(翼形布局)会绘制占位图形。占位图形不受 `image.visible` 或 `image.showBackground` 控制。 + +### subImage(string|HTMLImageElement|HTMLCanvasElement) + +`portrait`(纵向布局)中的错位装饰图,绘制在主图后方。省略时不绘制,不受 `image.showBackground` 控制。其他四种布局不使用此字段。 + +### marker(string) + +仅 `portrait`(纵向布局)生效的时间标记,例如 `'2024'`。各字符在中轴上纵向排列,字号和显隐通过顶层 `marker` 配置。 + +### datum(any) + +保留在节点中的原始业务数据,不参与布局或文本生成。 + +## layout(string|Object) = 'landscape' + +对应 `IStorylineSpec.layout`,类型为 `StorylineLayoutType | IStorylineLayoutOptions`。省略时使用 `'landscape'`(横向布局);字符串形式只选择布局,对象形式用于同时配置方向、角度、半径或内部留白。对象中的 `type` 必填,其余字段可选。 + +类型定义来自扩展包 `src/charts/storyline/interface.ts`: + +```ts +export type StorylineLayoutType = 'clock' | 'arc' | 'wing' | 'landscape' | 'portrait'; +export type StorylineWingDirection = 'left' | 'right'; +export type StorylineArcDirection = 'up' | 'down'; +export interface IStorylineLayoutOptions { + type: StorylineLayoutType; + padding?: number | [number, number, number, number]; + radiusRatio?: number; + startAngle?: number; + endAngle?: number; + direction?: StorylineWingDirection | StorylineArcDirection; +} +``` + +当前五种布局的参数适用范围: + +| type | 节点排列 | 生效的 layout 参数 | +| ----------------------- | ---------------------------------- | ---------------------------------------------------- | +| `landscape`(横向布局) | 横向图片序列,文字上下交替 | `padding` | +| `portrait`(纵向布局) | 沿中轴左右交替,支持时间标记 | `padding` | +| `clock`(时钟布局) | 按数组顺序沿圆形轨道顺时针等角排列 | 使用自动几何,其余 layout 字段不参与计算 | +| `arc`(弧形布局) | 穹顶或碗形弧线 | `direction`、`radiusRatio`、`startAngle`、`endAngle` | +| `wing`(翼形布局) | 左侧或右侧锚定的翼形弧线 | `direction`、`radiusRatio`、`startAngle`、`endAngle` | + +```ts +import type { IStorylineSpec } from '@visactor/vchart-extension'; + +const simpleLayout: IStorylineSpec['layout'] = 'portrait'; +const arcLayout: IStorylineSpec['layout'] = { + type: 'arc', + direction: 'down', + radiusRatio: 0.88, + startAngle: 20, + endAngle: 160 +}; +``` + +`direction` 的类型是四个方向的联合,但有效值与布局相关:`arc`(弧形布局)使用 `up/down`,`wing`(翼形布局)使用 `left/right`。各字段的运行时适用范围以本页表格为准。 + +### type(string) + +使用对象形式时必填。可选值为 `'landscape'`(横向布局)、`'portrait'`(纵向布局)、`'clock'`(时钟布局)、`'arc'`(弧形布局)、`'wing'`(翼形布局)。 + +### padding(number|Array) + +类型为 `number | [number, number, number, number]`,单位为像素。数值表示四边相同;数组必须按 `[top, right, bottom, left]` 给出。`landscape`(横向布局)、`portrait`(纵向布局)读取此参数来计算节点可用区域。 + +优先级为 `layout.padding` → `block.padding` → `24`,显式的 `0` 会被保留。它位于顶层 `padding` 划定的绘图区内部,两层留白会分别参与计算,不会互相覆盖。`clock`(时钟布局)、`arc`(弧形布局)和 `wing`(翼形布局)的专用几何不读取此字段。 + +### radiusRatio(number) + +`arc`(弧形布局)和 `wing`(翼形布局)的半径比例。`arc`(弧形布局)默认为 `0.88`,用于水平半径;垂直半径由主题图片高度与起始角度计算。`wing`(翼形布局)默认为 `0.92`,用于水平、垂直两个半径。`clock`(时钟布局)自动计算轨道半径,不使用此参数。 + +这是无单位的缩放系数,例如 `0.88` 表示按该布局计算的基准半径再乘以 `0.88`,并不表示图片宽度。类型只约束为 `number`,实现没有统一限制到 `[0, 1]`;增大比例可能让内容超出可用区域。 + +### startAngle(number) + +`arc`(弧形布局)或 `wing`(翼形布局)的起始角度,单位为度。 + +- `arc`(弧形布局):`up` 默认为 `200`,`down` 默认为 `20`。 +- `wing`(翼形布局):`left` 默认为 `-70`,`right` 默认为 `110`。 + +`clock`(时钟布局)当前不使用此参数。 + +使用屏幕坐标角度:`0°` 朝右,`90°` 朝下,`180°` 朝左,`270°` 朝上。`arc`(弧形布局)、`wing`(翼形布局)按起始角到结束角的差值排列节点。 + +### endAngle(number) + +`arc`(弧形布局)或 `wing`(翼形布局)的结束角度,单位为度。 + +- `arc`(弧形布局):`up` 默认为 `340`,`down` 默认为 `160`。 +- `wing`(翼形布局):`left` 默认为 `70`,`right` 默认为 `250`。 + +`clock`(时钟布局)当前不使用此参数。 + +与 `startAngle` 一起决定节点覆盖的弧段;两个及以上节点按数组顺序在该角度区间内插值,单节点取中间角度。 + +### direction(string) + +`arc`(弧形布局)或 `wing`(翼形布局)的方向,其他布局不使用此参数。 + +- `arc`(弧形布局):`'up'`(默认)为穹顶,主题图位于底部;`'down'` 为碗形,主题图位于顶部。 +- `wing`(翼形布局):`'left'`(默认)或 `'right'`,控制圆心的锚定方向。 + +## themeColor(string) = '#e8543d' + +主轴、连线、图片背景和强调元素的默认主题色。图元的显式样式配置优先。 + +## block(Object) + +节点尺寸与内部留白配置。`landscape`(横向布局)、`portrait`(纵向布局)使用节点盒子;`arc`(弧形布局)使用宽度参数计算水平几何和文本宽度。`clock`(时钟布局)、`wing`(翼形布局)使用各自的几何。 + +接口中的 `block.gap`、`block.showBackground` 仅供通用备用布局使用,当前五种公开布局不使用它们。图片背景通过 `image.showBackground` 控制。 + +### width(number) + +`landscape`(横向布局)、`portrait`(纵向布局)的节点固定宽度,优先于自适应宽度配置。`arc`(弧形布局)中还作为文本宽度上限,空间不足时会继续收缩。`clock`(时钟布局)和 `wing`(翼形布局)自动计算节点几何。 + +### widthRatio(number) = 0.24 + +自适应节点宽度与可用视图宽度的比例,受 `minWidth`、`maxWidth` 限制。`portrait`(纵向布局)使用此配置,`arc`(弧形布局)也将其用于弧线的水平空间计算。`landscape`(横向布局)未配置 `width` 时按节点数量计算宽度。 + +### minWidth(number) = 180 + +自适应节点宽度下限。配置了 `width` 时不生效,适用布局同 `widthRatio`。 + +### maxWidth(number) + +自适应节点宽度上限,默认是 `Math.max(minWidth, 320)`。配置了 `width` 时不生效,适用布局同 `widthRatio`。 + +### height(number) + +`landscape`(横向布局)、`portrait`(纵向布局)的节点基准高度。`landscape`(横向布局)默认 `320`。`portrait`(纵向布局)未配置时,使用 `Math.max(120, Math.floor(内部可用高度 / (data.length + 1)))`;实际图片高度默认取该值的 `0.6` 倍,正文区域高度至少取该值的 `1.25` 倍或三行正文的高度。 + +`clock`(时钟布局)、`arc`(弧形布局)和 `wing`(翼形布局)不使用此参数控制节点高度。 + +### padding(number|Array) + +内部留白,支持统一数值或 `[top, right, bottom, left]`。`landscape`(横向布局)、`portrait`(纵向布局)的节点位置计算中,作为 `layout.padding` 的回退值,默认 `24`;`clock`(时钟布局)也使用它计算轨道和文本区域,默认 `24`。 + +`landscape`(横向布局)还将此值用于图片宽度计算和图片最小高度计算,其中图片最小高度计算的局部默认值为 `12`。`arc`(弧形布局)、`wing`(翼形布局)的专用节点几何不使用此参数。 + +### style(Object) + +矩形背景样式。当前 `landscape`(横向布局)、`portrait`(纵向布局)会将其合并到图片背景上,需要同时开启 `image.showBackground`。例如 `{ fill: '#fff', stroke: '#3b82a0', lineWidth: 2 }`。 + +{{ use: graphic-rect(prefix = '###') }} + +## title(Object) + +每个节点的标题图元配置,文字来自 `data[].title`,默认最多显示两行。这里不是普通图表的标题组件,不使用顶层 `title.text` 或 `title.subtext`。 + +### visible(boolean) = true + +是否显示节点标题。 + +### style(Object) + +标题文字样式,例如 `{ fontSize: 20, lineHeight: 27, fontWeight: 'bold', fill: '#23364d' }`。`fontSize`、`lineHeight` 使用数值。 + +未指定字号时,`landscape`(横向布局)、`portrait`(纵向布局)自适应范围为 `8–34`,`clock`(时钟布局)为 `8–30`,`arc`(弧形布局)为 `10–40`;`wing`(翼形布局)按文本测量结果在 `14–30` 内调整。显式设置 `fontSize` 优先。默认行高为字号乘以布局比例后四舍五入:`landscape`(横向布局)、`portrait`(纵向布局)、`arc`(弧形布局)为 `1.35`,`clock`(时钟布局)为 `1.28`,`wing`(翼形布局)为 `1.3`。 + +标题默认加粗,带白色描边;`landscape`(横向布局)、`portrait`(纵向布局)、`arc`(弧形布局)使用深色填充,`clock`(时钟布局)、`wing`(翼形布局)使用主题色。位置、对齐和默认两行限制由布局设置,显式样式会覆盖图元属性。 + +{{ use: storyline-text-style(prefix = '###') }} + +## content(Object) + +节点正文图元配置,文字来自 `data[].content`。当前五种布局将段落连接为带换行的文本,可用宽高由布局决定。 + +### visible(boolean) = true + +是否显示正文。 + +### style(Object) + +正文文字样式,例如 `{ fontSize: 14, lineHeight: 21, fill: '#526174' }`。字号和行高使用数值。 + +默认字号 / 行高:`landscape`(横向布局)、`portrait`(纵向布局)为 `16 / 23`,`clock`(时钟布局)为 `14 / 20`,`wing`(翼形布局)为 `12 / 17`;`arc`(弧形布局)根据节点数量和可用空间缩放,以 `18 / 24` 为基准。`clock`(时钟布局)的默认正文高度固定为 `80`,调整行高不会同步增大文本区域。 + +`landscape`(横向布局)、`portrait`(纵向布局)、`arc`(弧形布局)默认使用 `heightLimit` 和省略号处理超出区域的正文;`wing`(翼形布局)允许长正文继续延伸,可能超出画布。可通过 `heightLimit`、`ellipsis` 调整文本显示,布局的节点间距不会随之自动增大。 + +{{ use: storyline-text-style(prefix = '###') }} + +## image(Object) + +对应 `IStorylineSpec.image`,类型为 `IStorylineImageSpec`,用于统一配置所有节点的主图片。每个节点的资源分别放在 `data[i].image` 中,主图配置对象本身没有顶层 `image` 资源字段。 + +```ts +import type { IMarkSpec, IImageMarkSpec } from '@visactor/vchart'; + +export type StorylineImagePosition = 'top' | 'left' | 'right' | 'bottom'; +export interface IStorylineImageSpec extends IMarkSpec { + width?: number; + height?: number; + position?: StorylineImagePosition; + gap?: number; + showBackground?: boolean; +} +``` + +该接口继承 `IMarkSpec`:`visible`、`interactive`、`zIndex`、`style`、`state` 等来自通用图元配置,其中 `style` 的准确类型为 `ConvertToMarkStyleSpec`。样式属性支持静态值和图元样式表达式;参与 Storyline 布局计算的顶层 `width`、`height` 则是像素数值。 + +各层配置的职责: + +| 配置位置 | 作用 | +| ---------------------- | --------------------------------------------------------------------- | +| `data[i].image` | 第 i 个节点的图片 URL、data URL、图片元素或画布元素 | +| `image.width/height` | 参与对应布局的节点图片尺寸计算 | +| `image.visible` | 控制已创建的主图片图元显隐 | +| `image.showBackground` | 控制布局单独创建的背景装饰 | +| `image.style` | 主图片的绘制属性,最后合并到布局计算的默认样式上 | +| `data[i].subImage` | `portrait`(纵向布局)中独立的错位装饰图,不继承 `image` 的样式和显隐 | + +类型仍声明 `position`(`top/left/right/bottom`)和 `gap`(像素间距),它们用于通用备用节点布局;当前五种公开布局自行安排图片与文字,不使用这两个参数。 + +下面用 `portrait`(纵向布局)同时设置布局尺寸与图片裁切方式: + +```ts +import type { IStorylineSpec } from '@visactor/vchart-extension'; + +const spec: IStorylineSpec = { + type: 'storyline', + width: 1080, + height: 1920, + layout: 'portrait', + image: { + width: 220, + height: 140, + visible: true, + showBackground: true, + style: { imageMode: 'cover', imagePosition: 'center', cornerRadius: 12 } + }, + data: [ + { + title: '1930', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-2022.png' + } + ] +}; +``` + +省略整个 `image` 配置时,只要节点提供 `data[i].image` 就会使用默认样式绘制主图。缺少图片资源时的占位行为见 `data.image`。 + +### visible(boolean) = true + +是否显示由 `data[].image` 创建的图片图元。不控制图片背景、缺少图片时的占位图形或 `data[].subImage`,也不会移除图片原有的布局空间。 + +### interactive(boolean) = false + +继承自 `IMarkSpec`。是否允许主图片图元响应交互;需要图片参与鼠标事件时设为 `true`。不会同步开启独立背景或 `subImage` 的交互。 + +### zIndex(number) + +继承自 `IMarkSpec`。覆盖图片图元的绘制层级;默认由具体布局决定。节点内图元仍受所属 group 的绘制层级约束。 + +### state(Object) + +继承自 `IMarkSpec` 的状态样式,支持 `normal`、`hover`、`hover_reverse`、`selected`、`selected_reverse` 及自定义状态名。状态值可以直接写图片样式,也可以使用包含 `style` 的完整状态配置。 + +需要通过鼠标触发状态时同时开启 `image.interactive`;只配置状态不会改变 Storyline 的节点布局。 + +### width(number) + +`portrait`(纵向布局)默认 `Math.max(blockWidth, 80)`。`wing`(翼形布局)以 `160` 为默认基准宽度,并沿主线进一步缩放。 + +`arc`(弧形布局)将 `Math.max(image.width ?? 240, image.height ?? 240)` 作为基准直径,再按可用空间缩放为正方形图片区域。因此只将一个维度设为小于 `240` 的值不会降低基准直径。 + +`landscape`(横向布局)和 `clock`(时钟布局)自动计算图片宽度,不使用此字段。 + +### height(number) + +`landscape`(横向布局)默认以节点高度的 `0.42` 倍作为图片高度,再结合标题行高、内部留白及画布空间调整;`portrait`(纵向布局)默认 `Math.round(blockHeight * 0.6)`。`wing`(翼形布局)以 `160` 为默认基准高度,并沿主线进一步缩放。 + +`arc`(弧形布局)与 `image.width` 一起决定基准直径,详见 `image.width`。`clock`(时钟布局)自动计算图片高度,不使用此字段。 + +### showBackground(boolean) + +是否显示图片背景装饰。`landscape`(横向布局)、`portrait`(纵向布局)默认为 `false`,`clock`(时钟布局)、`arc`(弧形布局)、`wing`(翼形布局)默认为 `true`。 + +该开关不控制主图、`subImage` 或缺少主图时的占位图形。需要主图隐藏时使用 `image.visible`。 + +### style(Object) + +类型来自 `IMarkSpec['style']`。先根据布局计算图元默认位置、尺寸和资源,再将本对象合并,显式样式优先。 + +| style 字段 | 默认值 / 作用 | +| ----------------------------- | ----------------------------------------------------------------------------------- | +| `imageMode` | `contain`:完整包含;可选 `cover`(铺满并裁切)、`fill`(拉伸)、`auto`(原始尺寸) | +| `imagePosition` | `center`;也支持方位关键字、组合方位和 `[x, y]` 元组,如 `['25%', '75%']` | +| `repeatX / repeatY` | `no-repeat`;可选 `repeat`、`stretch` | +| `imageScale` | 额外缩放图片内容,不改变布局尺寸 | +| `imageOffsetX / imageOffsetY` | 图片内容的像素偏移,不改变节点位置 | +| `opacity` | 整个图元透明度,范围 `0–1` | + +`imageMode`、`imagePosition`、`imageScale` 与图片内容偏移用于非重复平铺模式,保持 `repeatX`、`repeatY` 为 `no-repeat` 时使用。 + +修改 `image.width/height` 会参与适用布局的尺寸计算;修改 `image.style.width/height` 只改变主图绘制盒,背景、引导线和文本仍按原布局计算。`image.style.image` 也仅覆盖已创建的主图,无法替代节点的 `data[i].image` 来触发主图创建。 + +{{ use: graphic-image(prefix = '###') }} + +#### cornerRadius(number|Array) + +`landscape`(横向布局)、`portrait`(纵向布局)的主图默认不额外设置圆角;`clock`(时钟布局)、`arc`(弧形布局)、`wing`(翼形布局)的主图默认按半径做圆形裁切。可传统一数值或四项数组 `[左上, 右上, 右下, 左下]`,单位为像素。只控制图片图元的圆角;图片背景装饰单独计算。 + +## titleImage(Object) + +对应 `IStorylineSpec.titleImage`,类型为 `IStorylineTitleImageSpec`。这是整张图共用的主题图片,资源放在 `titleImage.image`;它与节点主图 `data[i].image`、节点装饰图 `data[i].subImage` 分别配置。 + +```ts +import type { IMarkSpec, IImageMarkSpec } from '@visactor/vchart'; + +export interface IStorylineTitleImageSpec extends IMarkSpec { + width?: number; + height?: number; + visible?: boolean; + image?: string | HTMLImageElement | HTMLCanvasElement; +} +``` + +该接口的四个自有字段 `image`、`visible`、`width`、`height` 均可选;但要实际创建主题图片,必须提供有效的 `image`。该接口继承 `IMarkSpec`:`visible`、`interactive`、`zIndex`、`style`、`state` 等来自通用图元配置,其中 `style` 的准确类型为 `ConvertToMarkStyleSpec`。样式属性支持静态值和图元样式表达式;参与 Storyline 布局计算的顶层 `width`、`height` 则是像素数值。 + +| 布局 | 默认主题图位置 | 默认尺寸计算使用的区域 | +| ---------------------------------------------------------------------- | ------------------------------ | ---------------------------- | +| `landscape`(横向布局) / `portrait`(纵向布局) / `clock`(时钟布局) | 画布顶部居中,距顶部 `12` 像素 | 整个画布 | +| `arc`(弧形布局) / `up` | 绘图区底部居中 | 扣除图表 padding 后的 region | +| `arc`(弧形布局) / `down` | 绘图区顶部居中 | 扣除图表 padding 后的 region | +| `wing`(翼形布局) / `left` | 画布顶部靠右,距顶部 `12` 像素 | 整个画布 | +| `wing`(翼形布局) / `right` | 画布顶部靠左,距顶部 `12` 像素 | 整个画布 | + +`clock`(时钟布局)使用顶部主题图。`width`、`height` 是布局盒的像素尺寸,默认值见子配置项;图片内容通过 `style.imageMode` 在盒内缩放,而不是根据资源原始宽高决定布局盒。 + +```ts +import type { IStorylineSpec } from '@visactor/vchart-extension'; + +const titleImage: IStorylineSpec['titleImage'] = { + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/title-world-cap.png', + visible: true, + width: 520, + height: 180, + style: { imageMode: 'contain', imagePosition: 'center', opacity: 0.9 } +}; +``` + +### image(string|HTMLImageElement|HTMLCanvasElement) + +图片资源类型为 `string | HTMLImageElement | HTMLCanvasElement`,可传图片 URL、data URL、已加载的图片元素或画布元素。 + +未配置时不创建主题图片。创建条件读取这里的资源,因此只设置 `titleImage.style.image` 无法创建主题图。已创建图元后,`style.image` 可以覆盖其绘制资源;常规用法应把资源统一放在本字段。 + +### visible(boolean) = true + +是否创建主题图片,默认 `true`;没有 `titleImage.image` 时仍不会创建。设为 `false` 时,`landscape`(横向布局)、`portrait`(纵向布局)、`clock`(时钟布局)和 `wing`(翼形布局)不再为主题图追加顶部留白,布局的基础 padding 仍会保留。 + +`arc`(弧形布局)的半径和位置计算始终会使用主题图盒的尺寸,包括默认尺寸;隐藏主题图片不会自动收起这部分布局空间。 + +`titleImage.style.visible: false` 只隐藏已创建的图元,仍保留顶部预留空间;需要关闭主题图片时优先用本字段。 + +### interactive(boolean) = false + +继承自 `IMarkSpec`。是否允许主题图片图元响应交互;需要图片参与鼠标事件时设为 `true`。不会同步开启独立背景或 `subImage` 的交互。 + +### zIndex(number) + +继承自 `IMarkSpec`。覆盖图片图元的绘制层级;默认由具体布局决定。节点内图元仍受所属 group 的绘制层级约束。 + +### state(Object) + +继承自 `IMarkSpec` 的状态样式,支持 `normal`、`hover`、`hover_reverse`、`selected`、`selected_reverse` 及自定义状态名。状态值可以直接写图片样式,也可以使用包含 `style` 的完整状态配置。 + +需要通过鼠标触发状态时同时开启 `titleImage.interactive`;只配置状态不会改变 Storyline 的节点布局。 + +### width(number) + +主题图片宽度。`landscape`(横向布局)、`portrait`(纵向布局)、`clock`(时钟布局)默认取画布宽度的 `0.52` 倍,上限 `720`;`wing`(翼形布局)默认取画布宽度的 `0.6` 倍,上限 `820`;`arc`(弧形布局)默认取 region 宽度的 `0.68` 倍,上限 `900`。 + +显式宽度覆盖上述默认值,`arc`(弧形布局)最终宽度至少 `80`,其余布局至少 `1`。 + +### height(number) + +主题图片高度。`landscape`(横向布局)、`portrait`(纵向布局)、`clock`(时钟布局)、`wing`(翼形布局)默认取主题图片宽度的 `0.36` 倍,且不超过画布高度;`arc`(弧形布局)默认取宽度的 `0.34` 倍。 + +显式高度覆盖上述默认值,`arc`(弧形布局)最终高度至少 `40`,其余布局至少 `1`。 + +### style(Object) + +类型来自 `IMarkSpec['style']`。先根据布局计算图元默认位置、尺寸和资源,再将本对象合并,显式样式优先。 + +| style 字段 | 默认值 / 作用 | +| ----------------------------- | ----------------------------------------------------------------------------------- | +| `imageMode` | `contain`:完整包含;可选 `cover`(铺满并裁切)、`fill`(拉伸)、`auto`(原始尺寸) | +| `imagePosition` | `center`;也支持方位关键字、组合方位和 `[x, y]` 元组,如 `['25%', '75%']` | +| `repeatX / repeatY` | `no-repeat`;可选 `repeat`、`stretch` | +| `imageScale` | 额外缩放图片内容,不改变布局尺寸 | +| `imageOffsetX / imageOffsetY` | 图片内容的像素偏移,不改变节点位置 | +| `opacity` | 整个图元透明度,范围 `0–1` | + +`imageMode`、`imagePosition`、`imageScale` 与图片内容偏移用于非重复平铺模式,保持 `repeatX`、`repeatY` 为 `no-repeat` 时使用。 + +修改 `titleImage.width/height` 会参与主题图布局和空间预留;修改 `titleImage.style.width/height`、`x/y`、`dx/dy` 只覆盖绘制属性,不会同步重新计算顶部留白或 `arc`(弧形布局)的几何参数。 + +{{ use: graphic-image(prefix = '###') }} + +#### cornerRadius(number|Array) + +主题图片默认不额外设置圆角。可传统一数值或四项数组 `[左上, 右上, 右下, 左下]`,单位为像素。只控制图片图元的圆角;图片背景装饰单独计算。 + +## line(Object) + +布局主线或中轴配置。`landscape`(横向布局)、`portrait`(纵向布局)、`arc`(弧形布局)、`wing`(翼形布局)分别生成自己的曲线、中轴、弧线和主线;`clock`(时钟布局)使用固定轨道和引线。 + +接口中的 `line.type`、`line.showArrow`、`line.arrowSize`、`line.distance` 用于通用备用连线,当前五种公开布局的主线不使用这些字段。 + +### visible(boolean) + +- `landscape`(横向布局):默认显示,至少两个节点时绘制主曲线和节点圆点;设为 `false` 隐藏二者,但保留每个节点的文字引导线。 +- `portrait`(纵向布局):默认显示,至少两个节点时绘制中轴;设为 `false` 隐藏中轴及其时间标记。 +- `arc`(弧形布局):默认隐藏,显式设为 `true` 才显示主弧线;节点文字引导线独立显示。 +- `wing`(翼形布局):默认显示,设为 `false` 隐藏主线;节点文字引导线独立显示。 +- `clock`(时钟布局):不使用此参数,轨道与引线始终按布局内置规则绘制。 + +### style(Object) + +`landscape`(横向布局)的曲线支持 `stroke`、`lineWidth` 和 `lineDash`;`portrait`(纵向布局)的中轴使用 `fill`、`stroke`、`lineWidth` 和 `cornerRadius`;`wing`(翼形布局)的主线使用 `fill` 或 `stroke` 作为填充色。`arc`(弧形布局)和 `clock`(时钟布局)不使用此配置,颜色由 `themeColor` 决定。 + +主线形状由布局生成,不通过 `style.path` 或 `style.points` 配置。 + +#### stroke(string|Object|boolean) + +`landscape`(横向布局)的曲线或 `portrait`(纵向布局)的中轴描边。`wing`(翼形布局)未设置 `fill` 时,还会将 `stroke` 用作主线填充色。 + +#### fill(string|Object) + +`portrait`(纵向布局)的中轴或 `wing`(翼形布局)的主线填充色。`portrait`(纵向布局)默认使用主题色渐变,`wing`(翼形布局)默认使用主题色。 + +#### lineWidth(number) + +`landscape`(横向布局)的曲线线宽(默认 `4`)或 `portrait`(纵向布局)的中轴描边宽度(默认 `0`),单位为像素。`wing`(翼形布局)还将其作为主线末端宽度的回退配置。 + +#### lineDash(Array) + +`landscape`(横向布局)的曲线虚线模式,默认 `[6, 5]`。该值也会用于 `landscape`(横向布局)的节点文字连接线。 + +#### cornerRadius(number|Array) + +`portrait`(纵向布局)的中轴圆角,默认 `0`。 + +## marker(Object) + +`portrait`(纵向布局)的时间标记配置,文字来自 `data[].marker`。标记在中轴上逐字纵向排列,其他布局不使用此配置。 + +### visible(boolean) = true + +是否显示时间标记。中轴隐藏或节点没有 `marker` 值时,也不会绘制标记。 + +### style(Object) + +时间标记样式,常用 `fontSize` 和 `lineHeight`,均设置为数值;省略时根据画布、标记长度和槽位空间自动计算。标记的默认字色是白色,单字符富文本样式由布局生成。 + +#### fontSize(number) + +标记文字字号,单位为像素。未指定时根据画布、文本长度和中轴槽位计算,默认自适应范围为 `16–38`。 + +#### lineHeight(number) + +纵向排列时每个字符的行高,默认 `Math.round(实际字号 * 0.9)`。 + +#### fontWeight(string|number) = 'bold' + +标记文字字重。 + +#### shadowColor(string) = 'rgba(0, 0, 0, 0.3)' + +文字阴影颜色。 + +#### shadowBlur(number) = 8 + +文字阴影模糊半径。 + +#### shadowOffsetX(number) = 0 + +文字阴影的水平偏移。 + +#### shadowOffsetY(number) = 5 + +文字阴影的垂直偏移。 + +{{ target: storyline-text-style }} + +#${prefix} fontSize(number) + +文字字号,单位为像素。 + +#${prefix} lineHeight(number) + +文字行高,单位为像素。 + +#${prefix} fontFamily(string) + +文字字体。 + +#${prefix} fontWeight(string|number) + +文字字重,例如 `'normal'`、`'bold'` 或 `500`。 + +#${prefix} fill(string|Object) + +文字填充色。 + +#${prefix} stroke(string|Object|boolean) + +文字描边颜色,设置为 `false` 可关闭描边。 + +#${prefix} lineWidth(number) + +文字描边宽度,单位为像素。 + +#${prefix} textAlign(string) + +水平对齐方式,例如 `'left'`、`'center'`、`'right'`。默认由布局决定。 + +#${prefix} textBaseline(string) + +垂直对齐基线,例如 `'top'`、`'middle'`、`'bottom'`。默认由布局决定。 + +#${prefix} maxLineWidth(number) + +最大行宽。覆盖布局计算的行宽时,需为相邻节点预留足够空间。 + +#${prefix} opacity(number) + +文字透明度,范围为 `0` 到 `1`。 + +#${prefix} dx(number) + +相对布局位置的水平偏移,单位为像素。 + +#${prefix} dy(number) + +相对布局位置的垂直偏移,单位为像素。 + +#${prefix} heightLimit(number) + +文本绘制高度上限,单位为像素。只影响文字的显示范围,不改变布局中的节点间距。 + +#${prefix} lineClamp(number) + +最多显示的文本行数。标题默认 `2`;修改时需要同时调整文本高度。 + +#${prefix} ellipsis(string|boolean) + +文本超出限制时的省略标记,例如 `'...'`。 + +#${prefix} whiteSpace(string) + +换行方式,布局默认 `'normal'`。 + +#${prefix} wordBreak(string) + +单词换行方式,布局默认 `'break-word'`。 diff --git a/docs/assets/option/zh/option.md b/docs/assets/option/zh/option.md index deb0f87587..d0d459c5ce 100644 --- a/docs/assets/option/zh/option.md +++ b/docs/assets/option/zh/option.md @@ -67,3 +67,5 @@ {{import: chart-common}} {{import: chart-image-cloud}} + +{{import: chart-storyline}} diff --git a/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts b/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts new file mode 100644 index 0000000000..206d31708f --- /dev/null +++ b/packages/vchart-extension/__tests__/runtime/browser/test-page/storyline.ts @@ -0,0 +1,387 @@ +import { VChart } from '@visactor/vchart'; +import { registerStorylineChart } from '../../../../src'; +import type { IStorylineSpec, StorylineLayoutType } from '../../../../src/charts/storyline'; + +const layouts: StorylineLayoutType[] = ['landscape', 'portrait', 'ladder', 'spiral', 'clock', 'arc', 'wing']; + +const SUB_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-2022.png'; +const TITLE_IMAGE_URL = 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/title-world-cap.png'; + +const baseData = [ + { + id: 'discover', + title: 'Discover', + content: + 'Collect the first signal and frame the story. Capture every relevant detail from the source material ' + + 'so the audience can reconstruct the same context the author had when starting the analysis.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'group', + title: 'Group', + content: + 'Arrange related facts into a compact block, removing duplicates and aligning each fragment ' + + 'to the central theme so readers can scan supporting evidence at a glance without losing context.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'connect', + title: 'Connect', + content: + 'Draw the reading path between blocks. Use repeating motifs, parallel sentence structures ' + + 'and visual cues to establish a continuous flow that walks the reader from premise to conclusion.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'emphasize', + title: 'Emphasize', + content: + 'Use image, title, and copy as one visual unit. Highlight the most important facts with typography ' + + 'weight, color contrast or motion so the eye instinctively returns to them while scanning.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + }, + { + id: 'resolve', + title: 'Resolve', + content: + 'End with a clear takeaway. Summarize the lesson, point out the next decision the audience ' + + 'should make and remove any ambiguity so the story closes with a satisfying, actionable conclusion.' + + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + subImage: SUB_IMAGE_URL + } +]; + +const randomCountByLayout = layouts.reduce>( + (result, layout) => { + result[layout] = 3 + Math.floor(Math.random() * 7); + return result; + }, + {} as Record +); + +const buildData = (layout: StorylineLayoutType) => { + const count = randomCountByLayout[layout]; + return Array.from({ length: count }, (_, index) => { + const seed = baseData[index % baseData.length]; + // portrait 布局:附加 marker 时间节点(2012、2013…)以便沿中轴纵向展示 + const marker = layout === 'portrait' ? String(2012 + index) : undefined; + return { + ...seed, + id: `${layout}-${index}-${seed.id}`, + title: `${seed.title} ${index + 1}`, + content: [`${seed.content}`, `Layout ${layout} / Block ${index + 1} of ${count}.`], + ...(marker ? { marker } : {}) + }; + }); +}; + +// 通用 title / content 样式(所有布局共享) +const commonTitle: IStorylineSpec['title'] = { + style: { + // fontSize: 14, + // fill: '#1f2533', + // fontWeight: 700 + } +}; + +const commonContent: IStorylineSpec['content'] = { + style: { + // fontSize: 12, + // lineHeight: 17, + // fill: '#596579' + } +}; + +const commonLine: IStorylineSpec['line'] = { + type: 'line', + showArrow: true, + style: { + lineWidth: 1.5, + lineCap: 'round', + lineJoin: 'round', + lineDash: [6, 5] + } +}; + +const themeColor = 'rgb(228,154,56)'; +const titleImage: IStorylineSpec['titleImage'] = { + image: TITLE_IMAGE_URL +}; + +const WIDTH = 1920; +const HEIGHT = 1080; + +// landscape:图片错落 + 贯穿曲线,block 含上下两个卡片,垂直空间更大 +const createLandscapeSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ + type: 'storyline', + padding: 20, + width: WIDTH, + height: HEIGHT, + data: buildData(layout), + layout, + titleImage, + themeColor, + line: commonLine, + image: { + showBackground: false + } +}); + +// portrait:默认 block.height = regionHeight / count,imageHeight = blockHeight * 0.4, +// contentHeight = blockHeight * 0.6;底部 padding 默认 = contentHeight,由 transformer 自动应用。 +const createPortraitSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ + type: 'storyline', + height: WIDTH, + width: HEIGHT, + data: buildData(layout), + layout, + titleImage, + themeColor +}); + +// arc:弧形布局,通过 direction 切换 dome('up')/ bowl('down') +const createArcSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ + type: 'storyline', + // padding: 0, + width: WIDTH, + height: HEIGHT, + data: buildData(layout), + layout: { type: 'arc', direction: 'up' }, + titleImage, + themeColor +}); + +// clock:环绕式时间线 +const createClockSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ + type: 'storyline', + height: HEIGHT, + width: WIDTH, + // padding: [20, 20, 50, 20], + layout: 'clock', + titleImage, + themeColor, + // background: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png', + data: [ + { + id: 'uruguay-1930', + title: '首届世界杯诞生', + content: + '1930年7月,国际足联首届世界杯在乌拉圭蒙得维的亚开幕,仅有十三支球队参赛。' + + '东道主乌拉圭借助世纪球场坐镇,决赛中以4比2逆转近邻阿根廷,捧起了雷米特金杯。' + + '乌拉圭队队长纳萨齐高举奖杯的画面,从此奠定了世界杯作为全球足球最高荣誉的象征意义。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'brazil-1958', + title: '贝利天才登场', + content: + '1958年瑞典世界杯成为足球新王登基的舞台。年仅十七岁的贝利首次代表巴西出战,在四分之一决赛对威尔士贡献关键进球,半决赛对法国上演帽子戏法,决赛对东道主瑞典再度梅开二度,帮助巴西首夺世界杯冠军。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'mexico-1986', + title: '马拉多纳神迹', + content: + '1986年墨西哥世界杯由马拉多纳一人定义。四分之一决赛对英格兰,' + + '他先用左手将球送入网窝制造『上帝之手』,紧接着又从中圈带球连过五人攻入世纪进球,' + + '让阿根廷在马岛战争阴影下挣得舆论高地。半决赛对比利时再献两粒精彩入球,最终阿根廷3比2夺冠。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'france-1998', + title: '齐祖之夜法兰西', + content: + '1998年法国世界杯由东道主自己谱写童话。决赛在圣丹尼新落成的法兰西大球场进行,' + + '齐达内两次起跳头槌破门,将卫冕冠军巴西打懵,最终法国3比0大胜首夺世界杯。' + + '比赛终场哨响时,香榭丽舍大街涌入百万球迷,蓝白红的海洋与齐达内剪影一同映在凯旋门上。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'germany-2014', + title: '战车碾过马拉卡纳', + content: + '2014年巴西世界杯德国队成为最大赢家。半决赛德国在贝洛奥里藏特7比1血洗东道主巴西,决赛在传奇的马拉卡纳球场进行,加时赛第113分钟,戈策胸停凌空抽射打进绝杀,德国时隔24年再夺世界杯。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'qatar-2022', + title: '梅西终圆封王梦', + content: + '2022年卡塔尔世界杯成为首届在中东和北半球冬季举行的世界杯。决赛在卢赛尔体育场进行,' + + '阿根廷与法国上演被誉为史上最经典的对决。梅西梅开二度,' + + '姆巴佩则上演世界杯决赛六十五年来首个帽子戏法,常规及加时赛战成3比3。' + + '点球大战中阿根廷4比2取胜。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + } + ] +}); + +const createDefaultSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ + type: 'storyline', + // padding: 20, + data: buildData(layout), + layout, + titleImage, + themeColor, + block: { + widthRatio: 0.28, + minWidth: 220, + maxWidth: 320, + height: 192, + padding: 12, + gap: 40, + style: { fill: '#ffffff', stroke: '#d8deea', lineWidth: 1, cornerRadius: 8 } + }, + image: { position: 'left', gap: 12 }, + title: commonTitle, + content: commonContent, + line: commonLine +}); + +// wing:椭圆弧时间线(参考残奥历史信息图),通过 layout.direction 切换左/右翅膀 +const createWingSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ + type: 'storyline', + // padding: [40, 40, 40, 40], + height: WIDTH, + width: HEIGHT, + data: buildData(layout), + layout: { type: 'wing', direction: 'left' }, + titleImage, + themeColor +}); + +// ladder:参考 Bauhaus 信息图 —— 中央倾斜大字 headline + 两侧错落 block +// 通过 layout.direction('up' | 'down')控制对角线方向 +const createLadderSpec = (layout: StorylineLayoutType): IStorylineSpec => ({ + type: 'storyline', + width: 1600, + height: 900, + // padding: 20, + layout: { type: 'ladder', direction: 'down', headline: 'ladder' }, + themeColor: '#C8102E', + background: 'transparent', + data: [ + { + id: 'uruguay-1930', + title: '首届世界杯诞生', + content: + '1930年7月,国际足联首届世界杯在乌拉圭蒙得维的亚开幕,仅有十三支球队参赛。' + + '东道主乌拉圭坐镇世纪球场,决赛中以4比2逆转近邻阿根廷,捧起了雷米特金杯。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'brazil-1958', + title: '贝利天才登场', + content: + '1958年瑞典世界杯成为足球新王登基的舞台。年仅十七岁的贝利首次代表巴西出战,' + + '在四分之一决赛对威尔士贡献关键进球,半决赛对法国上演帽子戏法,' + + '决赛对东道主瑞典再度梅开二度,帮助巴西首夺世界杯冠军。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'mexico-1986', + title: '马拉多纳神迹', + content: + '1986年墨西哥世界杯由马拉多纳一人定义。四分之一决赛对英格兰,' + + '他先用左手将球送入网窝制造『上帝之手』,紧接着又从中圈带球连过五人攻入世纪进球,' + + '让阿根廷在马岛战争阴影下挣得舆论高地。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'france-1998', + title: '齐祖之夜法兰西', + content: + '1998年法国世界杯由东道主自己谱写童话。决赛在圣丹尼新落成的法兰西大球场进行,' + + '齐达内两次起跳头槌破门,将卫冕冠军巴西打懵,最终法国3比0大胜首夺世界杯。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'germany-2014', + title: '战车碾过马拉卡纳', + content: + '2014年巴西世界杯德国队成为最大赢家。半决赛德国在贝洛奥里藏特7比1血洗东道主巴西,' + + '决赛在传奇的马拉卡纳球场进行,加时赛第113分钟,戈策胸停凌空抽射打进绝杀,' + + '德国时隔24年再夺世界杯。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + }, + { + id: 'qatar-2022', + title: '梅西终圆封王梦', + content: + '2022年卡塔尔世界杯成为首届在中东和北半球冬季举行的世界杯。决赛在卢赛尔体育场进行,' + + '阿根廷与法国上演被誉为史上最经典的对决。梅西梅开二度,' + + '姆巴佩则上演世界杯决赛六十五年来首个帽子戏法,常规及加时赛战成3比3。' + + '点球大战中阿根廷4比2取胜。', + image: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/node-world-cup-1930.png' + } + ] +}); + +const specBuilderByLayout: Partial IStorylineSpec>> = { + landscape: createLandscapeSpec, + portrait: createPortraitSpec, + clock: createClockSpec, + arc: createArcSpec, + wing: createWingSpec, + ladder: createLadderSpec +}; + +const createSpec = (layout: StorylineLayoutType): IStorylineSpec => { + const builder = specBuilderByLayout[layout] ?? createDefaultSpec; + return builder(layout); +}; + +declare global { + interface Window { + vchart?: VChart; + } +} + +const run = () => { + registerStorylineChart(); + + const container = document.getElementById('chart') as HTMLElement; + const toolbar = document.createElement('div'); + toolbar.style.cssText = 'position:absolute;left:16px;top:16px;z-index:1;font:12px sans-serif;'; + const select = document.createElement('select'); + layouts.forEach(layout => { + const option = document.createElement('option'); + option.value = layout; + option.textContent = layout; + select.appendChild(option); + }); + toolbar.appendChild(select); + container?.parentElement?.appendChild(toolbar); + + let cs: VChart | undefined; + + const render = (layout: StorylineLayoutType) => { + cs?.release(); + cs = new VChart(createSpec(layout) as any, { + dom: container, + onError: err => { + console.error(err); + } + }); + cs.renderSync(); + window.vchart = cs; + }; + + select.value = 'arc'; + render(select.value as StorylineLayoutType); + + select.addEventListener('change', () => { + render(select.value as StorylineLayoutType); + }); +}; + +run(); diff --git a/packages/vchart-extension/src/charts/storyline/index.ts b/packages/vchart-extension/src/charts/storyline/index.ts new file mode 100644 index 0000000000..2c4b70f598 --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/index.ts @@ -0,0 +1,4 @@ +export * from './interface'; +export * from './layout'; +export * from './storyline'; +export * from './storyline-transformer'; diff --git a/packages/vchart-extension/src/charts/storyline/interface.ts b/packages/vchart-extension/src/charts/storyline/interface.ts new file mode 100644 index 0000000000..db21bc9594 --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/interface.ts @@ -0,0 +1,272 @@ +import type { + IChartSpec, + IComposedTextMarkSpec, + IImageMarkSpec, + IMarkSpec, + IPathMarkSpec, + IRectMarkSpec, + ITextMarkSpec, + StringOrNumber +} from '@visactor/vchart'; + +/** + * Storyline 图表支持的布局类型。 + */ +export type StorylineLayoutType = 'clock' | 'arc' | 'wing' | 'landscape' | 'portrait'; + +/** + * block 内图片相对文本内容的摆放位置。 + */ +export type StorylineImagePosition = 'top' | 'left' | 'right' | 'bottom'; +/** + * block 之间连线的绘制方式。 + */ +export type StorylineLineType = 'line' | 'polyline' | 'curve'; +/** + * wing 布局的展开方向。 + */ +export type StorylineWingDirection = 'left' | 'right'; +/** + * arc 布局的弧形方向。 + */ +export type StorylineArcDirection = 'up' | 'down'; + +/** + * Storyline 中单个叙事节点的数据配置。 + */ +export interface IStorylineBlock { + /** + * 节点唯一标识。未配置时使用数据索引作为内部标识。 + */ + id?: StringOrNumber; + /** + * 节点标题文本。 + */ + title?: string; + /** + * 节点正文内容。传入数组时会按段落拼接为富文本内容。 + */ + content?: string | string[]; + /** + * 节点主图片,支持图片 URL、HTMLImageElement 或 HTMLCanvasElement。 + */ + image?: string | HTMLImageElement | HTMLCanvasElement; + /** + * 绘制在主 image 背后的装饰图(如 portrait 布局的错位 shadow image)。 + * 若未配置,则不会绘制装饰 image。 + */ + subImage?: string | HTMLImageElement | HTMLCanvasElement; + /** + * 时间节点文本(如 "2012")。 + * 仅 portrait 布局生效:在中轴 rect 上沿每个 block 的 center.y 处纵向绘制。 + * 配合 spec.marker 控制样式与显隐。 + */ + marker?: string; + /** + * 绑定到该节点的原始业务数据,供外部交互或扩展逻辑使用。 + */ + datum?: unknown; +} + +/** + * Storyline 布局参数配置。 + */ +export interface IStorylineLayoutOptions { + /** + * 布局类型。 + */ + type: StorylineLayoutType; + /** + * 边缘留白,支持单值或 [top, right, bottom, left]。 + */ + padding?: number | [number, number, number, number]; + /** + * 对 circular/arc 布局生效,控制半径占可用空间的比例。 + */ + radiusRatio?: number; + /** + * 对 circular/arc 布局生效,角度单位为度。 + */ + startAngle?: number; + /** + * 对 circular/arc 布局生效,角度单位为度。 + */ + endAngle?: number; + /** + * 方向控制: + * - wing 布局:'left' | 'right',圆心锚位置; + * - arc 布局:'up' | 'down','up' 表示穹顶(titleImage 贴底,弧线在上方),'down' 表示碗形(titleImage 贴顶,弧线在下方)。 + */ + direction?: StorylineWingDirection | StorylineArcDirection; +} + +/** + * Storyline 节点 block 的尺寸、间距、背景和样式配置。 + */ +export interface IStorylineBlockSpec { + /** + * block 的固定宽度。配置后优先级高于 widthRatio、minWidth、maxWidth。 + */ + width?: number; + /** + * block 宽度相对可用视图宽度的比例。 + */ + widthRatio?: number; + /** + * block 自适应宽度的最小值。 + */ + minWidth?: number; + /** + * block 自适应宽度的最大值。 + */ + maxWidth?: number; + /** + * block 的固定高度。 + */ + height?: number; + /** + * block 内边距,支持单值或 [top, right, bottom, left]。 + */ + padding?: number | [number, number, number, number]; + /** + * block 之间的布局间距。 + */ + gap?: number; + /** + * 是否展示 block 背后的卡片背景 rect(白底 + 描边 + 阴影)。 + * 默认 false(不展示)。 + */ + showBackground?: boolean; + /** + * block 背景 rect 的图形样式配置。 + */ + style?: Partial; +} + +/** + * Storyline 节点主图片配置。 + */ +export interface IStorylineImageSpec extends IMarkSpec { + /** + * 节点主图片宽度。 + */ + width?: number; + /** + * 节点主图片高度。 + */ + height?: number; + /** + * 节点主图片相对标题和正文的位置。 + */ + position?: StorylineImagePosition; + /** + * 节点主图片与文本内容之间的间距。 + */ + gap?: number; + /** + * 是否展示 image 背后的白色背景 rect(白底 + 主题色描边)。 + * 所有布局支持。 + * portrait / landscape 默认 false(不展示),其他布局默认 true(展示)。 + * 注:不影响 subImage(错位装饰图元)的显隐。 + */ + showBackground?: boolean; +} + +/** + * Storyline 标题图片配置,用于在不同布局中放置主题图片或标题图。 + */ +export interface IStorylineTitleImageSpec extends IMarkSpec { + /** + * 标题图片宽度。未配置时由布局根据画布尺寸自适应计算。 + */ + width?: number; + /** + * 标题图片高度。未配置时由布局根据宽度和默认比例自适应计算。 + */ + height?: number; + /** + * 是否显示标题图片。 + */ + visible?: boolean; + /** + * 标题图片资源,支持图片 URL、HTMLImageElement 或 HTMLCanvasElement。 + */ + image?: string | HTMLImageElement | HTMLCanvasElement; +} + +/** + * Storyline 连接线配置。 + */ +export interface IStorylineLineSpec extends IMarkSpec { + /** + * 是否显示连接线或布局主轴线。 + */ + visible?: boolean; + /** + * 连接线类型。 + */ + type?: StorylineLineType; + /** + * 是否在线段末端显示箭头。 + */ + showArrow?: boolean; + /** + * 箭头尺寸。 + */ + arrowSize?: number; + /** + * 连接线和 block 边缘之间的距离。 + */ + distance?: number; +} + +/** + * Storyline 图表总配置。 + */ +export interface IStorylineSpec extends Omit { + /** + * 图表类型,固定为 'storyline'。 + */ + type: 'storyline'; + /** + * Storyline 节点数据数组。 + */ + data: IStorylineBlock[]; + /** + * 布局类型或布局参数配置。 + */ + layout?: StorylineLayoutType | IStorylineLayoutOptions; + /** + * 节点 block 的尺寸、间距、背景和样式配置。 + */ + block?: IStorylineBlockSpec; + /** + * 节点标题文本 mark 配置。 + */ + title?: IMarkSpec; + /** + * 图表标题图片配置。 + */ + titleImage?: IStorylineTitleImageSpec; + /** + * 节点正文富文本 mark 配置。 + */ + content?: IMarkSpec; + /** + * 节点主图片配置。 + */ + image?: IStorylineImageSpec; + /** + * 节点之间连接线或布局主轴线配置。 + */ + line?: IStorylineLineSpec; + /** + * 时间节点文本配置(仅 portrait 布局生效)。 + * 当 spec.data[i].marker 有值时,在中轴 rect 上沿垂直方向绘制每个 block 的时间节点文本。 + */ + marker?: IMarkSpec; + /** + * Storyline 主题色,用于连接线、轴、图片背景和强调元素的默认颜色。 + */ + themeColor?: string; +} diff --git a/packages/vchart-extension/src/charts/storyline/layout.ts b/packages/vchart-extension/src/charts/storyline/layout.ts new file mode 100644 index 0000000000..98cf0a5f64 --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/layout.ts @@ -0,0 +1,320 @@ +import type { IStorylineBlock, IStorylineLayoutOptions, StorylineLayoutType } from './interface'; + +export interface StorylineSize { + width: number; + height: number; +} + +export interface StorylinePadding { + top: number; + right: number; + bottom: number; + left: number; +} + +export interface StorylinePoint { + x: number; + y: number; +} + +export interface StorylineBlockPosition extends StorylinePoint, StorylineSize { + id: string | number; + index: number; + datum: IStorylineBlock; + center: StorylinePoint; +} + +export interface StorylineLinkPosition { + from: StorylineBlockPosition; + to: StorylineBlockPosition; + start: StorylinePoint; + end: StorylinePoint; + points: StorylinePoint[]; +} + +export interface StorylineCircleGuide { + center: StorylinePoint; + radius: number; +} + +export interface StorylineLayoutResult { + blocks: StorylineBlockPosition[]; + links: StorylineLinkPosition[]; + circleGuide?: StorylineCircleGuide; +} + +export interface StorylineComputeOptions { + layout: StorylineLayoutType | IStorylineLayoutOptions | undefined; + viewBox: StorylineSize; + block: StorylineSize; + padding?: number | [number, number, number, number]; + lineDistance?: number; +} + +const DEFAULT_LAYOUT: StorylineLayoutType = 'landscape'; +const DEFAULT_PADDING = 24; + +export const normalizePadding = ( + padding?: number | [number, number, number, number] | { top?: number; right?: number; bottom?: number; left?: number } +): StorylinePadding => { + if (Array.isArray(padding)) { + return { + top: padding[0] ?? 0, + right: padding[1] ?? 0, + bottom: padding[2] ?? 0, + left: padding[3] ?? 0 + }; + } + if (padding && typeof padding === 'object' && 'top' in padding) { + return { + top: (padding as { top?: number }).top ?? 0, + right: (padding as { right?: number }).right ?? 0, + bottom: (padding as { bottom?: number }).bottom ?? 0, + left: (padding as { left?: number }).left ?? 0 + }; + } + const value = (padding as number | undefined) ?? DEFAULT_PADDING; + return { top: value, right: value, bottom: value, left: value }; +}; + +export const normalizeLayout = (layout?: StorylineLayoutType | IStorylineLayoutOptions): IStorylineLayoutOptions => { + if (!layout) { + return { type: DEFAULT_LAYOUT }; + } + if (typeof layout === 'string') { + return { type: layout }; + } + return layout; +}; + +export const computeStorylineLayout = ( + data: IStorylineBlock[], + options: StorylineComputeOptions +): StorylineLayoutResult => { + const layout = normalizeLayout(options.layout); + const padding = normalizePadding(layout.padding ?? options.padding); + const lineDistance = options.lineDistance ?? 8; + const blocks = computeBlockPositions(data, layout, options.viewBox, options.block, padding); + const circleGuide = + layout.type === 'clock' ? computeClockCircleGuide(options.viewBox, options.block, padding, layout) : undefined; + return { + blocks, + links: computeLinks(blocks, lineDistance), + circleGuide + }; +}; + +const computeBlockPositions = ( + data: IStorylineBlock[], + layout: IStorylineLayoutOptions, + viewBox: StorylineSize, + block: StorylineSize, + padding: StorylinePadding +): StorylineBlockPosition[] => { + const count = data.length; + if (!count) { + return []; + } + + const inner = { + x: padding.left, + y: padding.top, + width: Math.max(viewBox.width - padding.left - padding.right, block.width), + height: Math.max(viewBox.height - padding.top - padding.bottom, block.height) + }; + const center = { + x: inner.x + inner.width / 2, + y: inner.y + inner.height / 2 + }; + + let centers: StorylinePoint[]; + switch (layout.type) { + case 'portrait': + centers = lineCenters( + count, + center.x, + inner.y + block.height / 2, + center.x, + inner.y + inner.height - block.height / 2 + ); + break; + case 'clock': + centers = circularCenters(count, viewBox, block, padding, layout); + break; + case 'arc': { + // arc 布局:通过 direction 控制 dome(穹顶)/ bowl(碗形)方向 + // - 'up'(默认):弧线在上方(穹顶),等同原 dome + // - 'down':弧线在下方(碗形),等同原 bowl + const isDown = layout.direction === 'down'; + const [s, e] = isDown ? [20, 160] : [200, 340]; + centers = arcCenters(count, inner, block, layout, s, e); + break; + } + case 'wing': { + const direction = layout.direction === 'right' ? 'right' : 'left'; + const [s, e] = direction === 'right' ? [110, 250] : [-70, 70]; + centers = arcCenters(count, inner, block, layout, s, e); + break; + } + case 'landscape': + default: + centers = lineCenters( + count, + inner.x + block.width / 2, + center.y, + inner.x + inner.width - block.width / 2, + center.y + ); + break; + } + + return centers.map((point, index) => ({ + id: data[index]?.id ?? index, + index, + datum: data[index], + width: block.width, + height: block.height, + x: point.x - block.width / 2, + y: point.y - block.height / 2, + center: point + })); +}; + +const lineCenters = (count: number, x0: number, y0: number, x1: number, y1: number): StorylinePoint[] => { + if (count === 1) { + return [{ x: (x0 + x1) / 2, y: (y0 + y1) / 2 }]; + } + return Array.from({ length: count }, (_, index) => { + const t = index / (count - 1); + return { + x: x0 + (x1 - x0) * t, + y: y0 + (y1 - y0) * t + }; + }); +}; + +const circularCenters = ( + count: number, + viewBox: StorylineSize, + block: StorylineSize, + padding: StorylinePadding, + layout: IStorylineLayoutOptions +) => { + const guide = computeClockCircleGuide(viewBox, block, padding, layout); + const startAngle = layout.startAngle ?? -90; + const delta = 360; + + if (count === 1) { + const angle = degreeToRadian(startAngle); + return [ + { + x: guide.center.x + Math.cos(angle) * guide.radius, + y: guide.center.y + Math.sin(angle) * guide.radius + } + ]; + } + + return Array.from({ length: count }, (_, index) => { + const angle = degreeToRadian(startAngle + (delta * index) / count); + return { + x: guide.center.x + Math.cos(angle) * guide.radius, + y: guide.center.y + Math.sin(angle) * guide.radius + }; + }); +}; + +const computeClockCircleGuide = ( + viewBox: StorylineSize, + block: StorylineSize, + padding: StorylinePadding, + layout: IStorylineLayoutOptions +): StorylineCircleGuide => { + const innerWidth = Math.max(viewBox.width - padding.left - padding.right, 1); + const innerHeight = Math.max(viewBox.height - padding.top - padding.bottom, 1); + const center = { + x: padding.left + innerWidth / 2, + y: padding.top + innerHeight / 2 + }; + const ratio = layout.radiusRatio ?? 0.7; + const maxRadius = Math.max(Math.min(innerWidth - block.width, innerHeight - block.height) / 2, 1); + + return { + center, + radius: Math.max(1, maxRadius * ratio) + }; +}; + +const arcCenters = ( + count: number, + inner: { x: number; y: number; width: number; height: number }, + block: StorylineSize, + layout: IStorylineLayoutOptions, + fallbackStartAngle?: number, + fallbackEndAngle?: number, + defaultRatio = 0.88 +) => { + const startAngle = layout.startAngle ?? fallbackStartAngle ?? -90; + const endAngle = layout.endAngle ?? fallbackEndAngle ?? 270; + const ratio = layout.radiusRatio ?? defaultRatio; + const rx = Math.max((inner.width - block.width) / 2, 1) * ratio; + const ry = Math.max((inner.height - block.height) / 2, 1) * ratio; + const center = { + x: inner.x + inner.width / 2, + y: inner.y + inner.height / 2 + }; + + if (count === 1) { + const angle = degreeToRadian((startAngle + endAngle) / 2); + return [{ x: center.x + Math.cos(angle) * rx, y: center.y + Math.sin(angle) * ry }]; + } + + return Array.from({ length: count }, (_, index) => { + const t = index / (count - 1); + const angle = degreeToRadian(startAngle + angleDelta(startAngle, endAngle) * t); + return { + x: center.x + Math.cos(angle) * rx, + y: center.y + Math.sin(angle) * ry + }; + }); +}; + +const angleDelta = (startAngle: number, endAngle: number) => { + const delta = endAngle - startAngle; + return Math.abs(delta) >= 360 ? 360 : delta; +}; + +const degreeToRadian = (degree: number) => (degree / 180) * Math.PI; + +const computeLinks = (blocks: StorylineBlockPosition[], distance: number): StorylineLinkPosition[] => { + const links: StorylineLinkPosition[] = []; + for (let i = 0; i < blocks.length - 1; i++) { + const from = blocks[i]; + const to = blocks[i + 1]; + const start = pointOnBlockEdge(from, to.center, distance); + const end = pointOnBlockEdge(to, from.center, distance); + links.push({ + from, + to, + start, + end, + points: [start, end] + }); + } + return links; +}; + +const pointOnBlockEdge = (block: StorylineBlockPosition, toward: StorylinePoint, distance: number): StorylinePoint => { + const dx = toward.x - block.center.x; + const dy = toward.y - block.center.y; + if (dx === 0 && dy === 0) { + return { x: block.center.x, y: block.center.y }; + } + const scaleX = dx === 0 ? Number.POSITIVE_INFINITY : block.width / 2 / Math.abs(dx); + const scaleY = dy === 0 ? Number.POSITIVE_INFINITY : block.height / 2 / Math.abs(dy); + const scale = Math.min(scaleX, scaleY); + const length = Math.sqrt(dx * dx + dy * dy) || 1; + return { + x: block.center.x + dx * scale + (dx / length) * distance, + y: block.center.y + dy * scale + (dy / length) * distance + }; +}; diff --git a/packages/vchart-extension/src/charts/storyline/layouts/arc.ts b/packages/vchart-extension/src/charts/storyline/layouts/arc.ts new file mode 100644 index 0000000000..df55bc43ae --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/layouts/arc.ts @@ -0,0 +1,610 @@ +import type { IExtensionGroupMarkSpec } from '@visactor/vchart'; +import { LayoutZIndex } from '@visactor/vchart'; +import type { IStorylineBlock, IStorylineSpec } from '../interface'; +import { + type ICustomMarkSpec, + type LayoutContext, + type StorylinePoint, + BLOCK_TITLE_MAX_LINES, + buildPlainContent, + getBlockTitleHeight, + getImageBackgroundStyle, + getRegionGeometry, + getThemeColor, + normalizeLayout, + omitImageLayoutSpec, + resolveAdaptiveLineHeight, + resolveBlockWidth, + resolveTitleFontSize, + shouldShowImageBackground, + withAlpha +} from './common'; + +// arc 布局:弧形排列 + titleImage(穹顶 / 碗形二合一) +// - direction = 'up'(默认):穹顶 —— titleImage 贴底,弧线在 titleImage 上方 +// - direction = 'down':碗形 —— titleImage 贴顶,弧线在 titleImage 下方 +// image 默认为圆形,ARC_BLOCK_IMAGE_SIZE 即圆的直径 +const ARC_BLOCK_IMAGE_SIZE = 240; +// 圆形 image 的边框环厚度(圆形描边宽度) +const ARC_BLOCK_IMAGE_BORDER = 3; +// 圆形 image 背景环相对 image 的外扩量(始终渲染一个比 image 略大的圆形 symbol 作为背景环) +const ARC_BLOCK_IMAGE_HALO_PADDING = 6; +const ARC_TEXT_GAP_FROM_IMAGE = 10; +const ARC_TITLE_FONT_SIZE = 32; +const ARC_TITLE_LINE_HEIGHT = 34; +const ARC_CONTENT_LINE_HEIGHT = 24; +const ARC_CONTENT_FONT_SIZE = 18; +// title + content 区域总高度(默认 240px,溢出由 heightLimit + ellipsis 自动截断) +const ARC_TEXT_BOX_HEIGHT = 240; +const ARC_TITLE_TO_CONTENT_GAP = 4; +// 引导线与 title/content 之间的水平间距 +const ARC_TEXT_PADDING = 20; +// title/content 区域的最小宽度,确保文字有足够展示空间,不受 image 宽度限制 +const ARC_TEXT_BOX_MIN_WIDTH = 96; +const ARC_TEXT_BOX_ABSOLUTE_MIN_WIDTH = 64; +const ARC_TEXT_BOX_MAX_WIDTH = 260; +// titleImage 默认尺寸 +const ARC_TITLE_IMAGE_WIDTH_RATIO = 0.68; +const ARC_TITLE_IMAGE_MAX_WIDTH = 900; +const ARC_TITLE_IMAGE_HEIGHT_RATIO = 0.34; +// 弧线最高/最低点距离 titleImage 顶部/底部的距离 +const ARC_GAP_FROM_TITLE_IMAGE = 200; +const ARC_FIT_MARGIN = 8; + +const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)); + +const isDownArc = (spec: IStorylineSpec) => normalizeLayout(spec.layout).direction === 'down'; + +type ArcGeometry = { + cx: number; + cy: number; + rx: number; + ry: number; + startAngle: number; + endAngle: number; + centerTop: number; + centerBottom: number; +}; + +/** + * 计算 arc 布局 titleImage 的 box:水平居中。 + * - up(dome):垂直贴底(位于 inner 区域底部) + * - down(bowl):垂直贴顶(位于 inner 区域顶部) + */ +const getArcTitleImageRect = (spec: IStorylineSpec, ctx: LayoutContext) => { + const { width, height, startX, startY } = getRegionGeometry(ctx); + const innerWidth = Math.max(width, 1); + const innerHeight = Math.max(height, 1); + const baseWidth = Math.min(innerWidth * ARC_TITLE_IMAGE_WIDTH_RATIO, ARC_TITLE_IMAGE_MAX_WIDTH); + const w = Math.max(spec.titleImage?.width ?? baseWidth, 80); + const h = Math.max(spec.titleImage?.height ?? w * ARC_TITLE_IMAGE_HEIGHT_RATIO, 40); + const cx = startX + innerWidth / 2; + const isDown = isDownArc(spec); + const top = isDown ? startY : startY + innerHeight - h; + return { x: cx - w / 2, y: top, width: w, height: h }; +}; + +/** + * 计算 arc 弧线的几何参数: + * - cx / rx / startAngle / endAngle 与 layout.ts 中 arcCenters 一致; + * - cy 与 ry 由两条对齐约束反推,使弧线起/终点 y 与 titleImage 端面对齐, + * 弧线极值点(顶点 / 底点)距离 titleImage 远端 ARC_GAP_FROM_TITLE_IMAGE。 + * + * up(dome):startAngle = 200°、endAngle = 340°(弧线在 titleImage 上方) + * cy + ry * sin(startAngle) = titleImageBottom + * cy - ry = titleImageTop - GAP + * → ry = (titleImageHeight + GAP) / (1 + sin(startAngle)) + * cy = titleImageBottom - ry * sin(startAngle) + * + * down(bowl):startAngle = 20°、endAngle = 160°(弧线在 titleImage 下方) + * cy + ry * sin(startAngle) = titleImageTop + * cy + ry = titleImageBottom + GAP + * → ry = (GAP + titleImageHeight) / (1 - sin(startAngle)) + * cy = titleImageTop - ry * sin(startAngle) + */ +const getBaseArcGeometry = (spec: IStorylineSpec, ctx: LayoutContext): ArcGeometry => { + const { width, startX } = getRegionGeometry(ctx); + // width 已经是 VChart 减去 spec.padding 后的 region 宽度 + const innerWidth = Math.max(width, 1); + const blockWidth = resolveBlockWidth(spec, width); + const layoutOpt = normalizeLayout(spec.layout); + const isDown = layoutOpt.direction === 'down'; + // 默认弧线起止角与 layout.ts 中一致 + const startAngle = layoutOpt.startAngle ?? (isDown ? 20 : 200); + const endAngle = layoutOpt.endAngle ?? (isDown ? 160 : 340); + const ratio = layoutOpt.radiusRatio ?? 0.88; + const rx = Math.max((innerWidth - blockWidth) / 2, 1) * ratio; + const titleImageRect = getArcTitleImageRect(spec, ctx); + const centerTop = titleImageRect.y; + const centerBottom = titleImageRect.y + titleImageRect.height; + const sinStart = Math.sin((startAngle / 180) * Math.PI); + let cy: number; + let ry: number; + if (isDown) { + // bowl:sinStart 接近 1 时 ry → ∞;这里限制下界以防 startAngle 配置异常 + const denom = Math.max(1 - sinStart, 0.05); + ry = (titleImageRect.height + ARC_GAP_FROM_TITLE_IMAGE) / denom; + cy = centerTop - ry * sinStart; + } else { + // dome:sinStart 接近 -1 时 ry → ∞ + const denom = Math.max(1 + sinStart, 0.05); + ry = (titleImageRect.height + ARC_GAP_FROM_TITLE_IMAGE) / denom; + cy = centerBottom - ry * sinStart; + } + return { + cx: startX + innerWidth / 2, + cy, + rx, + ry, + startAngle, + endAngle, + centerTop, + centerBottom + }; +}; + +/** + * 在 arc 弧线上按 index 采样 block 中心,与 arc 完全同步。 + * 同时让 block 沿弧线径向向外偏移 imageHeight/2, + * 使 image 内边贴在弧线上,image + text 整体位于弧线外侧。 + */ +const getArcBlockCenterByGeometry = ( + spec: IStorylineSpec, + arc: ArcGeometry, + index: number, + ctx?: LayoutContext +): StorylinePoint => { + const count = spec.data?.length ?? 0; + if (count <= 0) { + return { x: arc.cx, y: arc.cy }; + } + const t = count === 1 ? 0.5 : index / (count - 1); + const angle = ((arc.startAngle + (arc.endAngle - arc.startAngle) * t) / 180) * Math.PI; + const px = arc.cx + Math.cos(angle) * arc.rx; + const py = arc.cy + Math.sin(angle) * arc.ry; + // 椭圆在 (px,py) 处的外法向量 ∝ (cos(angle)/rx, sin(angle)/ry) + const nxRaw = Math.cos(angle) / arc.rx; + const nyRaw = Math.sin(angle) / arc.ry; + const nLen = Math.hypot(nxRaw, nyRaw) || 1; + const nx = nxRaw / nLen; + const ny = nyRaw / nLen; + const imageHeight = getArcBlockMetrics(spec, index, ctx).imageBox.height; + const offset = imageHeight / 2; + return { x: px + nx * offset, y: py + ny * offset }; +}; + +const getArcBlockBounds = (spec: IStorylineSpec, arc: ArcGeometry, index: number, ctx: LayoutContext) => { + const center = getArcBlockCenterByGeometry(spec, arc, index, ctx); + const metrics = getArcBlockMetrics(spec, index, ctx); + const halo = shouldShowImageBackground(spec) ? ARC_BLOCK_IMAGE_HALO_PADDING + ARC_BLOCK_IMAGE_BORDER : 0; + const minX = Math.min(metrics.imageBox.x - halo, metrics.textBox.x, metrics.contentBox.x); + const maxX = Math.max( + metrics.imageBox.x + metrics.imageBox.width + halo, + metrics.textBox.x + metrics.textBox.width, + metrics.contentBox.x + metrics.contentBox.width + ); + const minY = Math.min(metrics.imageBox.y - halo, metrics.textBox.y, metrics.contentBox.y); + const maxY = Math.max( + metrics.imageBox.y + metrics.imageBox.height + halo, + metrics.textBox.y + metrics.textBox.height, + metrics.contentBox.y + metrics.contentBox.height + ); + return { + left: center.x + minX, + right: center.x + maxX, + top: center.y + minY, + bottom: center.y + maxY + }; +}; + +const getArcBlocksBounds = (spec: IStorylineSpec, arc: ArcGeometry, ctx: LayoutContext) => { + const count = spec.data?.length ?? 0; + if (!count) { + return { left: arc.cx, right: arc.cx, top: arc.cy, bottom: arc.cy }; + } + return Array.from({ length: count }, (_, index) => getArcBlockBounds(spec, arc, index, ctx)).reduce( + (bounds, blockBounds) => ({ + left: Math.min(bounds.left, blockBounds.left), + right: Math.max(bounds.right, blockBounds.right), + top: Math.min(bounds.top, blockBounds.top), + bottom: Math.max(bounds.bottom, blockBounds.bottom) + }), + { + left: Number.POSITIVE_INFINITY, + right: Number.NEGATIVE_INFINITY, + top: Number.POSITIVE_INFINITY, + bottom: Number.NEGATIVE_INFINITY + } + ); +}; + +const getArcGeometry = (spec: IStorylineSpec, ctx: LayoutContext): ArcGeometry => { + const arc = getBaseArcGeometry(spec, ctx); + const region = getRegionGeometry(ctx); + const bounds = getArcBlocksBounds(spec, arc, ctx); + const fit = { + left: region.startX + ARC_FIT_MARGIN, + right: region.startX + region.width - ARC_FIT_MARGIN, + top: region.startY + ARC_FIT_MARGIN, + bottom: region.startY + region.height - ARC_FIT_MARGIN + }; + let shiftX = 0; + let shiftY = 0; + const boundsWidth = bounds.right - bounds.left; + const boundsHeight = bounds.bottom - bounds.top; + const fitWidth = fit.right - fit.left; + const fitHeight = fit.bottom - fit.top; + + if (boundsWidth > fitWidth) { + shiftX = (fit.left + fit.right - bounds.left - bounds.right) / 2; + } else if (bounds.left < fit.left) { + shiftX = fit.left - bounds.left; + } else if (bounds.right > fit.right) { + shiftX = fit.right - bounds.right; + } + + if (boundsHeight > fitHeight) { + shiftY = (fit.top + fit.bottom - bounds.top - bounds.bottom) / 2; + } else if (bounds.top < fit.top) { + shiftY = fit.top - bounds.top; + } else if (bounds.bottom > fit.bottom) { + shiftY = fit.bottom - bounds.bottom; + } + + return shiftX || shiftY ? { ...arc, cx: arc.cx + shiftX, cy: arc.cy + shiftY } : arc; +}; + +const getArcBlockCenter = (spec: IStorylineSpec, ctx: LayoutContext, index: number): StorylinePoint => + getArcBlockCenterByGeometry(spec, getArcGeometry(spec, ctx), index, ctx); + +/** + * 贯穿所有 block 的弧线 mark(path 通过沿椭圆采样实现,与 arc block 的弧形布局完全重合) + * + * 默认不展示,仅当用户在 spec.line.visible 显式置为 true 时才渲染。 + */ +export const buildArcMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + if (spec.line?.visible !== true) { + return null; + } + const themeColor = getThemeColor(spec); + return { + type: 'group' as any, + name: 'storyline-arc', + zIndex: LayoutZIndex.Mark, + children: [ + { + type: 'path', + name: 'storyline-arc-path', + interactive: false, + style: { + stroke: themeColor, + lineWidth: 2, + lineCap: 'round', + fill: 'transparent', + fillOpacity: 0, + path: (_d: unknown, ctx: LayoutContext) => { + const arc = getArcGeometry(spec, ctx); + const span = arc.endAngle - arc.startAngle; + const samples = 64; + const segments: string[] = []; + for (let i = 0; i <= samples; i++) { + const t = i / samples; + const angle = ((arc.startAngle + span * t) / 180) * Math.PI; + const x = arc.cx + Math.cos(angle) * arc.rx; + const y = arc.cy + Math.sin(angle) * arc.ry; + segments.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`); + } + return segments.join(' '); + } + } + } as ICustomMarkSpec<'path'> + ] + }; +}; + +export const buildArcTitleImageMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + if (!spec.titleImage?.image || spec.titleImage.visible === false) { + return null; + } + return { + type: 'group' as any, + name: 'storyline-arc-title-image', + zIndex: LayoutZIndex.Mark + 6, + children: [ + { + type: 'image', + name: 'storyline-arc-title-image-node', + interactive: false, + ...spec.titleImage, + style: { + x: (_d: unknown, ctx: LayoutContext) => { + return getArcTitleImageRect(spec, ctx).x; + }, + y: (_d: unknown, ctx: LayoutContext) => { + return getArcTitleImageRect(spec, ctx).y; + }, + width: (_d: unknown, ctx: LayoutContext) => { + return getArcTitleImageRect(spec, ctx).width; + }, + height: (_d: unknown, ctx: LayoutContext) => { + return getArcTitleImageRect(spec, ctx).height; + }, + image: spec.titleImage.image, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'contain', + imagePosition: 'center', + ...spec.titleImage.style + } + } as ICustomMarkSpec<'image'> + ] + }; +}; + +const getArcBlockMetrics = (spec: IStorylineSpec, index: number = 0, ctx?: LayoutContext) => { + const region = ctx ? getRegionGeometry(ctx, spec) : undefined; + const count = Math.max(spec.data?.length ?? 0, 1); + const regionWidth = Math.max( + region?.width ?? Number((spec.width as number | undefined) ?? ARC_BLOCK_IMAGE_SIZE * count), + 1 + ); + const regionHeight = Math.max( + region?.height ?? Number((spec.height as number | undefined) ?? ARC_TEXT_BOX_HEIGHT), + 1 + ); + const slotWidth = regionWidth / (count + 1); + const imageScale = clamp( + Math.min(regionWidth / Math.max(count * ARC_BLOCK_IMAGE_SIZE * 0.92, 1), regionHeight / 620), + 0.42, + 1 + ); + const textScale = clamp(Math.min(slotWidth / 180, imageScale), 0.48, 1); + const styleContent = spec.content?.style as Record | undefined; + const contentFontSize = Number(styleContent?.fontSize ?? Math.round(ARC_CONTENT_FONT_SIZE * textScale)); + const contentLineHeight = Number( + styleContent?.lineHeight ?? Math.round(Math.max(contentFontSize * 1.35, ARC_CONTENT_LINE_HEIGHT * textScale)) + ); + const titleToContentGap = ARC_TITLE_TO_CONTENT_GAP; + + // 强制 image 为正方形(直径),保证圆形裁切有效 + const configuredImageDiameter = Math.max( + spec.image?.width ?? ARC_BLOCK_IMAGE_SIZE, + spec.image?.height ?? ARC_BLOCK_IMAGE_SIZE + ); + const imageDiameter = Math.round(clamp(configuredImageDiameter * imageScale, 64, configuredImageDiameter)); + const imageWidth = imageDiameter; + const imageHeight = imageDiameter; + + const isDown = isDownArc(spec); + + // content 默认宽度 = 图表宽度 / (block 数量 + 1),让内容沿弧线均匀分布; + // 用户可通过 spec.block?.width 设置上限;窄宽度下仍会根据槽宽收缩,避免相邻 block 互相覆盖。 + const configuredTextWidth = Number((spec.block as { width?: number } | undefined)?.width ?? ARC_TEXT_BOX_MAX_WIDTH); + const defaultTextWidth = Math.round(slotWidth * 0.96); + const minTextWidth = Math.min( + ARC_TEXT_BOX_MIN_WIDTH, + Math.max(Math.round(slotWidth * 0.72), ARC_TEXT_BOX_ABSOLUTE_MIN_WIDTH) + ); + const textBoxWidth = Math.round( + clamp(Math.min(configuredTextWidth, defaultTextWidth), minTextWidth, ARC_TEXT_BOX_MAX_WIDTH) + ); + const titleFontSize = resolveTitleFontSize( + spec, + ctx, + spec.data?.[index]?.title, + textBoxWidth, + Math.round(ARC_TITLE_FONT_SIZE * textScale), + [10, 40] + ); + const titleLineHeight = resolveAdaptiveLineHeight(titleFontSize, spec.title?.style as any, ARC_TITLE_LINE_HEIGHT); + const titleHeight = getBlockTitleHeight(titleLineHeight, spec.data?.[index]?.title, textBoxWidth, titleFontSize); + // text 区域总高度固定为 ARC_TEXT_BOX_HEIGHT,content 占除 title 与间距外的全部高度 + const textHeight = Math.round(clamp(ARC_TEXT_BOX_HEIGHT * textScale, 88, ARC_TEXT_BOX_HEIGHT)); + const contentHeight = Math.max(textHeight - titleHeight - titleToContentGap, contentLineHeight); + + // 前 1/2 为左侧(奇数 count 时中间块也算左侧),右侧为后 1/2; + // 左侧 title/content 右对齐(贴引导线),右侧 title/content 左对齐(贴引导线) + const isLeftSide = index < count / 2; + const textAlign: 'left' | 'right' = isLeftSide ? 'right' : 'left'; + + // image 位于 block 中心(x=0) + const imageBox = { + x: -imageWidth / 2, + y: -imageHeight / 2, + width: imageWidth, + height: imageHeight + }; + // 左侧:text 右对齐,text 右边缘与引导线(x=0)保持 ARC_TEXT_PADDING 距离 + // 右侧:text 左对齐,text 左边缘与引导线(x=0)保持 ARC_TEXT_PADDING 距离 + // 引导线 rect 固定 center 在 x=0([-1, 1]) + const textPadding = Math.round(ARC_TEXT_PADDING * textScale); + const textBoxX = isLeftSide ? -textPadding - textBoxWidth : textPadding; + const textAnchorX = isLeftSide ? -textPadding : textPadding; + const textBox = { + x: textBoxX, + y: isDown ? imageBox.y + imageHeight + ARC_TEXT_GAP_FROM_IMAGE : imageBox.y - ARC_TEXT_GAP_FROM_IMAGE - textHeight, + width: textBoxWidth, + height: textHeight + }; + const contentBox = { + x: textBox.x, + y: textBox.y + titleHeight + titleToContentGap, + width: textBox.width, + height: contentHeight + }; + return { + titleFontSize, + titleLineHeight, + contentFontSize, + contentLineHeight, + imageBox, + textBox, + contentBox, + isDown, + textAlign, + textAnchorX + }; +}; + +export const buildArcBlockMark = ( + spec: IStorylineSpec, + block: IStorylineBlock, + index: number +): IExtensionGroupMarkSpec => { + const hasImage = !!block.image; + const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; + const themeColor = getThemeColor(spec); + const getMetrics = (ctx: LayoutContext) => getArcBlockMetrics(spec, index, ctx); + const getConnectorBox = (ctx: LayoutContext) => { + const metrics = getMetrics(ctx); + return { + x: -1, + y: metrics.isDown ? metrics.imageBox.y + metrics.imageBox.height : metrics.textBox.y, + width: 2, + height: metrics.isDown + ? Math.max(metrics.textBox.y + metrics.textBox.height - (metrics.imageBox.y + metrics.imageBox.height), 0) + : Math.max(metrics.imageBox.y - metrics.textBox.y, 0) + }; + }; + + return { + type: 'group' as any, + id: `storyline-block-${block.id ?? index}`, + name: `storyline-block-${index}`, + zIndex: LayoutZIndex.Mark + 1, + style: { + x: (_d: unknown, ctx: LayoutContext) => getArcBlockCenter(spec, ctx, index).x, + y: (_d: unknown, ctx: LayoutContext) => getArcBlockCenter(spec, ctx, index).y + }, + children: [ + // title / content 与 image 之间的垂直引导线(zIndex 最低,作为装饰) + { + type: 'rect', + name: `storyline-block-connector-${index}`, + interactive: false, + zIndex: LayoutZIndex.Mark + 2, + style: { + x: (_d: unknown, ctx: LayoutContext) => getConnectorBox(ctx).x, + y: (_d: unknown, ctx: LayoutContext) => getConnectorBox(ctx).y, + width: (_d: unknown, ctx: LayoutContext) => getConnectorBox(ctx).width, + height: (_d: unknown, ctx: LayoutContext) => getConnectorBox(ctx).height, + fill: themeColor, + fillOpacity: 0.6 + } + } as ICustomMarkSpec<'rect'>, + hasImage + ? ({ + type: 'image', + name: `storyline-block-image-${index}`, + interactive: false, + zIndex: LayoutZIndex.Mark + 3, + ...omitImageLayoutSpec(spec.image), + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.y, + width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.width, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.height, + cornerRadius: (_d: unknown, ctx: LayoutContext) => + Math.min(getMetrics(ctx).imageBox.width, getMetrics(ctx).imageBox.height) / 2, + image: block.image, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'contain', + imagePosition: 'center', + ...spec.image?.style + } + } as ICustomMarkSpec<'image'>) + : ({ + type: 'symbol', + name: `storyline-block-image-bg-${index}`, + interactive: false, + zIndex: LayoutZIndex.Mark + 3, + style: { + x: 0, + y: 0, + size: (_d: unknown, ctx: LayoutContext) => + Math.min(getMetrics(ctx).imageBox.width, getMetrics(ctx).imageBox.height), + symbolType: 'circle', + ...getImageBackgroundStyle(spec) + } + } as ICustomMarkSpec<'symbol'>), + // 圆形 image 的外层装饰环 + shouldShowImageBackground(spec) + ? ({ + type: 'symbol', + name: `storyline-block-image-halo-${index}`, + interactive: false, + zIndex: LayoutZIndex.Mark + 3, + style: { + x: 0, + y: 0, + size: (_d: unknown, ctx: LayoutContext) => + Math.min(getMetrics(ctx).imageBox.width, getMetrics(ctx).imageBox.height) + + ARC_BLOCK_IMAGE_HALO_PADDING * 2, + symbolType: 'circle', + fill: 'transparent', + stroke: withAlpha(themeColor, 0.82), + lineWidth: ARC_BLOCK_IMAGE_BORDER + } + } as ICustomMarkSpec<'symbol'>) + : null, + block.title + ? ({ + type: 'text', + name: `storyline-block-title-${index}`, + interactive: false, + zIndex: LayoutZIndex.Mark + 5, + ...spec.title, + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textAnchorX, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textBox.y, + text: block.title, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleLineHeight * BLOCK_TITLE_MAX_LINES, + heightLimit: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleLineHeight * BLOCK_TITLE_MAX_LINES, + lineClamp: BLOCK_TITLE_MAX_LINES, + fontSize: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleFontSize, + lineHeight: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleLineHeight, + fontWeight: 'bold', + fill: '#1f2430', + stroke: '#fff', + lineWidth: 5, + lineJoin: 'round', + textAlign: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textAlign, + textBaseline: 'top', + whiteSpace: 'normal', + wordBreak: 'break-word', + ellipsis: '...', + ...spec.title?.style + } + } as ICustomMarkSpec<'text'>) + : null, + contentText.length + ? ({ + type: 'text', + name: `storyline-block-content-${index}`, + interactive: false, + zIndex: LayoutZIndex.Mark + 4, + ...spec.content, + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textAnchorX, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.y, + width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.width, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.height, + maxLineWidth: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.width, + heightLimit: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.height, + text: buildPlainContent(contentText), + fontSize: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentFontSize, + lineHeight: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentLineHeight, + textAlign: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textAlign, + textBaseline: 'top', + whiteSpace: 'normal', + wordBreak: 'break-word', + ellipsis: '...', + fill: '#596173', + ...spec.content?.style + } + } as ICustomMarkSpec<'text'>) + : null + ].filter(Boolean) as ICustomMarkSpec[] + }; +}; diff --git a/packages/vchart-extension/src/charts/storyline/layouts/clock.ts b/packages/vchart-extension/src/charts/storyline/layouts/clock.ts new file mode 100644 index 0000000000..8695b963fb --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/layouts/clock.ts @@ -0,0 +1,463 @@ +import type { IExtensionGroupMarkSpec } from '@visactor/vchart'; +import { LayoutZIndex } from '@visactor/vchart'; +import type { IStorylineBlock, IStorylineSpec } from '../interface'; +import { + type ICustomMarkSpec, + type LayoutContext, + BLOCK_TITLE_MAX_LINES, + buildPlainContent, + getBlockTitleHeight, + getImageBackgroundStyle, + getRegionGeometry, + getThemeColor, + normalizePadding, + omitImageLayoutSpec, + resolveAdaptiveLineHeight, + resolveTitleFontSize, + shouldShowImageBackground, + withAlpha +} from './common'; + +/** + * clock 布局:环绕式时间线(orbit timeline) + * + * 视觉结构(参考报刊版式): + * + * ┌──────── 外侧文字段(title + content)─────────┐ + * │ │ + * ●●● │ ┌───── 虚线轨道圆环 ─────┐ │ + * 圆形 dot ──────引线──────┤ │ + * │ │ ◎ titleImage │ + * │ │ (大圆人像) │ + * │ └───────────────────────┘ + * └─────────────────────────────────────────────┘ + * + * - titleImage:可选中心图,位于版面中心 + * - 虚线轨道:紧贴中心区域外侧的圆环,提供时间线的视觉骨架 + * - 每个 block 由 1 个圆形小图(dot)压在轨道上,外加一段从 dot 引出的 title + content 文字 + * - block 沿轨道环绕分布(默认 360°);左半圆 block 文字 right-align,右半圆 block 文字 left-align + */ + +// ===== 半径配置(按可用半径的比例划分各圈层)===== +const CLOCK_CENTER_RADIUS_RATIO = 0.6; // 中心圆半径(更大) +const CLOCK_CENTER_IMAGE_INSET_RATIO = 0.9; // titleImage 相对中心圆的尺寸比例(留出环形空隙) +const CLOCK_ORBIT_RATIO = 0.68; // 虚线轨道半径 +const CLOCK_DOT_RATIO = 0.68; // 圆形小图(dot)中心所在半径(与轨道重合) +const CLOCK_TEXT_INNER_RATIO = 0.92; // block 文字段起始半径(距离圆心更远) +const CLOCK_TEXT_MAX_WIDTH = 280; // 文字段最大宽度 + +// ===== 元素尺寸 ===== +const CLOCK_DOT_DIAMETER_RATIO = 0.32; // dot 直径相对 R(更大) +const CLOCK_LEAD_LINE_GAP = 6; // dot 到引线起点的间距 px +const CLOCK_TEXT_GAP_FROM_LEAD = 8; // 引线到文字的间距 px +const CLOCK_ORBIT_DASH = [4, 4]; + +// ===== 文字 ===== +const CLOCK_TITLE_FONT_SIZE = 22; +const CLOCK_TITLE_LINE_HEIGHT = 28; +const CLOCK_CONTENT_FONT_SIZE = 14; +const CLOCK_CONTENT_LINE_HEIGHT = 20; +const CLOCK_CONTENT_LINES = 4; + +// ===== 几何 ===== + +type ClockGeometry = { + cx: number; + cy: number; + R: number; // 整盘外半径 + count: number; + step: number; +}; + +const getClockGeometry = (spec: IStorylineSpec, ctx: LayoutContext): ClockGeometry => { + const { width, height, startX, startY } = getRegionGeometry(ctx); + const padding = normalizePadding(spec.block?.padding); + const innerWidth = Math.max(width - padding.left - padding.right, 1); + const innerHeight = Math.max(height - padding.top - padding.bottom, 1); + const cx = startX + padding.left + innerWidth / 2; + const cy = startY + padding.top + innerHeight / 2; + // R 需要预留 text 向外延伸的空间: + // - title 在 anchor 朝向圆心一侧(不占用外圈空间) + // - content 在 anchor 远离圆心一侧,需要预留 content 的高度 + // - 水平方向:text 从 0.92R 向外延伸 CLOCK_TEXT_MAX_WIDTH + const textReserveX = CLOCK_TEXT_MAX_WIDTH; + const textReserveY = 4 + CLOCK_CONTENT_LINE_HEIGHT * CLOCK_CONTENT_LINES; + const rMaxX = (innerWidth / 2 - textReserveX) / CLOCK_TEXT_INNER_RATIO; + const rMaxY = (innerHeight / 2 - textReserveY) / CLOCK_TEXT_INNER_RATIO; + const R = Math.max(Math.min(rMaxX, rMaxY), 1); + const count = spec.data?.length ?? 0; + const step = count > 0 ? (Math.PI * 2) / count : 0; + return { cx, cy, R, count, step }; +}; + +/** + * 第 index 个 block 在轨道上的角度。 + * - 0° = 正上方(12 点钟) + * - 顺时针递增 + */ +const getClockBlockAngle = (geom: ClockGeometry, index: number) => -Math.PI / 2 + geom.step * (index + 0.5); + +const polar = (cx: number, cy: number, r: number, angle: number) => ({ + x: cx + Math.cos(angle) * r, + y: cy + Math.sin(angle) * r +}); + +/** + * 判断 block 在版面左半边还是右半边。 + * 用于决定 block 文字的对齐方向(左半边 right-align,右半边 left-align)。 + */ +const isOnLeftHalf = (angle: number) => Math.cos(angle) < 0; + +// ===== 中心圆 ===== + +export const buildClockTitleImageMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + if (spec.titleImage?.visible === false) { + return null; + } + const themeColor = getThemeColor(spec); + const hasImage = !!spec.titleImage?.image; + return { + type: 'group' as any, + name: 'storyline-clock-center', + zIndex: LayoutZIndex.Mark + 2, + children: [ + // titleImage 背后的高亮光晕(主题色透明色,营造"焦点"效果) + { + type: 'symbol', + name: 'storyline-clock-center-halo', + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => getClockGeometry(spec, ctx).cx, + y: (_d: unknown, ctx: LayoutContext) => getClockGeometry(spec, ctx).cy, + size: (_d: unknown, ctx: LayoutContext) => { + const g = getClockGeometry(spec, ctx); + return g.R * CLOCK_CENTER_RADIUS_RATIO * 2.16; + }, + symbolType: 'circle', + fill: withAlpha(themeColor, 0.28), + stroke: 'transparent' + } + } as ICustomMarkSpec<'symbol'>, + hasImage + ? ({ + type: 'image', + name: 'storyline-clock-center-image', + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => { + const g = getClockGeometry(spec, ctx); + return g.cx - g.R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO; + }, + y: (_d: unknown, ctx: LayoutContext) => { + const g = getClockGeometry(spec, ctx); + return g.cy - g.R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO; + }, + width: (_d: unknown, ctx: LayoutContext) => + getClockGeometry(spec, ctx).R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO * 2, + height: (_d: unknown, ctx: LayoutContext) => + getClockGeometry(spec, ctx).R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO * 2, + image: spec.titleImage?.image, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'contain', + imagePosition: 'center', + cornerRadius: (_d: unknown, ctx: LayoutContext) => + getClockGeometry(spec, ctx).R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO, + // 默认锚点设为 image 中心,让 scaleX/scaleY 从中心缩放 + anchor: (_d: unknown, ctx: LayoutContext) => { + const g = getClockGeometry(spec, ctx); + return [g.cx, g.cy]; + }, + // 若用户在 style 里覆盖了 width/height,自动追加 dx/dy 让图片仍以 rect 中心为中心 + dx: (_d: unknown, ctx: LayoutContext) => { + const g = getClockGeometry(spec, ctx); + const rectW = g.R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO * 2; + const userWidth = (spec.titleImage?.style as { width?: number } | undefined)?.width; + const w = typeof userWidth === 'number' ? userWidth : rectW; + return (rectW - w) / 2; + }, + dy: (_d: unknown, ctx: LayoutContext) => { + const g = getClockGeometry(spec, ctx); + const rectH = g.R * CLOCK_CENTER_RADIUS_RATIO * CLOCK_CENTER_IMAGE_INSET_RATIO * 2; + const userHeight = (spec.titleImage?.style as { height?: number } | undefined)?.height; + const h = typeof userHeight === 'number' ? userHeight : rectH; + return (rectH - h) / 2; + }, + ...spec.titleImage?.style + } + } as ICustomMarkSpec<'image'>) + : ({ + type: 'symbol', + name: 'storyline-clock-center-placeholder', + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => getClockGeometry(spec, ctx).cx, + y: (_d: unknown, ctx: LayoutContext) => getClockGeometry(spec, ctx).cy, + size: (_d: unknown, ctx: LayoutContext) => getClockGeometry(spec, ctx).R * CLOCK_CENTER_RADIUS_RATIO * 2, + symbolType: 'circle', + fill: '#ffffff', + stroke: themeColor, + lineWidth: 2 + } + } as ICustomMarkSpec<'symbol'>) + ].filter(Boolean) as ICustomMarkSpec[] + }; +}; + +// ===== 虚线轨道 ===== + +/** + * 紧贴中心区域外侧的虚线圆环轨道。 + */ +export const buildClockArcMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + const themeColor = getThemeColor(spec); + + const orbitPath = (_d: unknown, ctx: LayoutContext) => { + const g = getClockGeometry(spec, ctx); + const r = g.R * CLOCK_ORBIT_RATIO; + return [ + `M ${(g.cx + r).toFixed(2)} ${g.cy.toFixed(2)}`, + `A ${r.toFixed(2)} ${r.toFixed(2)} 0 1 1 ${(g.cx - r).toFixed(2)} ${g.cy.toFixed(2)}`, + `A ${r.toFixed(2)} ${r.toFixed(2)} 0 1 1 ${(g.cx + r).toFixed(2)} ${g.cy.toFixed(2)}` + ].join(' '); + }; + + return { + type: 'group' as any, + name: 'storyline-clock-orbit', + zIndex: LayoutZIndex.Mark, + children: [ + { + type: 'path', + name: 'storyline-clock-orbit-path', + interactive: false, + style: { + path: orbitPath, + stroke: withAlpha(themeColor, 0.7), + lineWidth: 1, + lineDash: CLOCK_ORBIT_DASH, + fill: 'transparent', + fillOpacity: 0 + } + } as ICustomMarkSpec<'path'> + ] + }; +}; + +// ===== block:dot + 引线 + 文字段 ===== + +const getClockDotCenter = (spec: IStorylineSpec, ctx: LayoutContext, index: number) => { + const g = getClockGeometry(spec, ctx); + const angle = getClockBlockAngle(g, index); + const r = g.R * CLOCK_DOT_RATIO; + return { ...polar(g.cx, g.cy, r, angle), diameter: g.R * CLOCK_DOT_DIAMETER_RATIO, angle }; +}; + +/** + * 引线(dot 外缘 → 文字段内边)的两个端点。 + */ +const getClockLeadLine = (spec: IStorylineSpec, ctx: LayoutContext, index: number) => { + const g = getClockGeometry(spec, ctx); + const angle = getClockBlockAngle(g, index); + const dotR = (g.R * CLOCK_DOT_DIAMETER_RATIO) / 2; + const start = polar(g.cx, g.cy, g.R * CLOCK_DOT_RATIO + dotR + CLOCK_LEAD_LINE_GAP, angle); + const end = polar(g.cx, g.cy, g.R * CLOCK_TEXT_INNER_RATIO - CLOCK_TEXT_GAP_FROM_LEAD, angle); + return { start, end }; +}; + +/** + * block 文字段的矩形(中心 + 宽高 + 对齐)。 + * 文字段沿水平方向从 dot 一侧外延: + * - 左半圆:文字右对齐,向左延伸至画布左边界(含 padding) + * - 右半圆:文字左对齐,向右延伸至画布右边界(含 padding) + * 这样所有 block 的可用宽度都是一致的"画布半宽 - 中心圆半径",避免出现窄文字。 + */ +const getClockTextRect = (spec: IStorylineSpec, ctx: LayoutContext, index: number) => { + const g = getClockGeometry(spec, ctx); + const { width: regionWidth, startX } = getRegionGeometry(ctx); + const padding = normalizePadding(spec.block?.padding); + const angle = getClockBlockAngle(g, index); + const onLeft = isOnLeftHalf(angle); + // 文字段从 dot 外侧的 inner ring 处开始水平延展 + const rInner = g.R * CLOCK_TEXT_INNER_RATIO; + const innerPoint = polar(g.cx, g.cy, rInner, angle); + // 画布水平边界(含 padding) + const leftEdge = startX + padding.left; + const rightEdge = startX + regionWidth - padding.right; + const width = onLeft + ? Math.min(Math.max(innerPoint.x - leftEdge, 80), CLOCK_TEXT_MAX_WIDTH) + : Math.min(Math.max(rightEdge - innerPoint.x, 80), CLOCK_TEXT_MAX_WIDTH); + return { x: innerPoint.x, y: innerPoint.y, width, onLeft, anchorY: innerPoint.y }; +}; + +export const buildClockBlockMark = ( + spec: IStorylineSpec, + block: IStorylineBlock, + index: number +): IExtensionGroupMarkSpec => { + const hasImage = !!block.image; + const themeColor = getThemeColor(spec); + const showImageBackground = shouldShowImageBackground(spec); + const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; + const getTitleFontSize = (ctx: LayoutContext) => + resolveTitleFontSize( + spec, + ctx, + block.title, + getClockTextRect(spec, ctx, index).width, + CLOCK_TITLE_FONT_SIZE, + [8, 30] + ); + const getTitleLineHeight = (ctx: LayoutContext) => + resolveAdaptiveLineHeight(getTitleFontSize(ctx), spec.title?.style as any, CLOCK_TITLE_LINE_HEIGHT, 1.28); + const getTitleHeight = (ctx: LayoutContext) => + getBlockTitleHeight( + getTitleLineHeight(ctx), + block.title, + getClockTextRect(spec, ctx, index).width, + getTitleFontSize(ctx) + ); + + const leadPath = (_d: unknown, ctx: LayoutContext) => { + const { start, end } = getClockLeadLine(spec, ctx, index); + return `M ${start.x.toFixed(2)} ${start.y.toFixed(2)} L ${end.x.toFixed(2)} ${end.y.toFixed(2)}`; + }; + + const children: (ICustomMarkSpec | null)[] = [ + // 引线:从 dot 外缘到文字段内边 + { + type: 'path', + name: `storyline-clock-lead-${index}`, + interactive: false, + style: { + path: leadPath, + stroke: withAlpha(themeColor, 0.7), + lineWidth: 1, + lineDash: [3, 3], + fill: 'transparent', + fillOpacity: 0 + } + } as ICustomMarkSpec<'path'>, + showImageBackground + ? ({ + type: 'symbol', + name: `storyline-clock-dot-bg-${index}`, + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).x, + y: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).y, + size: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).diameter + 10, + symbolType: 'circle', + ...getImageBackgroundStyle(spec) + } + } as ICustomMarkSpec<'symbol'>) + : null, + // 圆形小图(dot):压在轨道上,作为时间锚点 + hasImage + ? ({ + type: 'image', + name: `storyline-clock-dot-${index}`, + interactive: false, + ...omitImageLayoutSpec(spec.image), + style: { + x: (_d: unknown, ctx: LayoutContext) => { + const dot = getClockDotCenter(spec, ctx, index); + return dot.x - dot.diameter / 2; + }, + y: (_d: unknown, ctx: LayoutContext) => { + const dot = getClockDotCenter(spec, ctx, index); + return dot.y - dot.diameter / 2; + }, + width: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).diameter, + height: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).diameter, + image: block.image, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'contain', + imagePosition: 'center', + cornerRadius: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).diameter / 2, + ...spec.image?.style + } + } as ICustomMarkSpec<'image'>) + : ({ + type: 'symbol', + name: `storyline-clock-dot-${index}`, + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).x, + y: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).y, + size: (_d: unknown, ctx: LayoutContext) => getClockDotCenter(spec, ctx, index).diameter, + symbolType: 'circle', + fill: themeColor, + stroke: '#ffffff', + lineWidth: 1.5 + } + } as ICustomMarkSpec<'symbol'>), + // title:最多两行 + block.title + ? ({ + type: 'text', + name: `storyline-clock-title-${index}`, + interactive: false, + ...spec.title, + style: { + x: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).x, + y: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).anchorY - getTitleHeight(ctx), + text: block.title, + height: (_d: unknown, ctx: LayoutContext) => getTitleHeight(ctx), + heightLimit: (_d: unknown, ctx: LayoutContext) => getTitleLineHeight(ctx) * BLOCK_TITLE_MAX_LINES, + lineClamp: BLOCK_TITLE_MAX_LINES, + fontSize: (_d: unknown, ctx: LayoutContext) => getTitleFontSize(ctx), + lineHeight: (_d: unknown, ctx: LayoutContext) => getTitleLineHeight(ctx), + fontWeight: 'bold', + fill: themeColor, + stroke: '#fff', + lineWidth: 5, + lineJoin: 'round', + textAlign: (_d: unknown, ctx: LayoutContext) => + getClockTextRect(spec, ctx, index).onLeft ? 'right' : 'left', + textBaseline: 'top', + whiteSpace: 'normal', + wordBreak: 'break-word', + ellipsis: '...', + ...spec.title?.style + } + } as ICustomMarkSpec<'text'>) + : null, + // content:普通文本,title 下方 + contentText.length + ? ({ + type: 'text', + name: `storyline-clock-content-${index}`, + interactive: false, + ...spec.content, + style: { + x: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).x, + y: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).anchorY + 4, + width: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).width, + height: CLOCK_CONTENT_LINE_HEIGHT * CLOCK_CONTENT_LINES, + maxLineWidth: (_d: unknown, ctx: LayoutContext) => getClockTextRect(spec, ctx, index).width, + heightLimit: CLOCK_CONTENT_LINE_HEIGHT * CLOCK_CONTENT_LINES, + text: buildPlainContent(contentText), + fontSize: CLOCK_CONTENT_FONT_SIZE, + lineHeight: CLOCK_CONTENT_LINE_HEIGHT, + fill: '#3a3f4d', + textAlign: (_d: unknown, ctx: LayoutContext) => + getClockTextRect(spec, ctx, index).onLeft ? 'right' : 'left', + textBaseline: 'top', + whiteSpace: 'normal', + wordBreak: 'break-word', + ...spec.content?.style + } + } as ICustomMarkSpec<'text'>) + : null + ]; + + return { + type: 'group' as any, + id: `storyline-block-${block.id ?? index}`, + name: `storyline-block-${index}`, + zIndex: LayoutZIndex.Mark + 1, + children: children.filter(Boolean) as ICustomMarkSpec[] + }; +}; diff --git a/packages/vchart-extension/src/charts/storyline/layouts/common.ts b/packages/vchart-extension/src/charts/storyline/layouts/common.ts new file mode 100644 index 0000000000..4c75592ecc --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/layouts/common.ts @@ -0,0 +1,543 @@ +import { LayoutZIndex, type ICustomMarkSpec } from '@visactor/vchart'; +import type { IStorylineBlock, IStorylineSpec, StorylineImagePosition } from '../interface'; +import { + computeStorylineLayout, + normalizeLayout, + normalizePadding, + type StorylineLayoutResult, + type StorylinePoint +} from '../layout'; + +// ===== 布局通用类型 ===== + +export type LayoutContext = { + chart?: { + getAllRegions?: () => { + getLayoutRect?: () => { width?: number; height?: number }; + getLayoutStartPoint?: () => { x?: number; y?: number }; + }[]; + getLayoutRect?: () => { width?: number; height?: number }; + }; + getLayoutBounds?: () => { width?: () => number; height?: () => number }; +}; + +// ===== 通用默认值 ===== + +export const DEFAULT_BLOCK_WIDTH = 180; +export const DEFAULT_BLOCK_HEIGHT = 400; +export const DEFAULT_BLOCK_WIDTH_RATIO = 0.24; +export const DEFAULT_IMAGE_WIDTH = 48; +export const DEFAULT_IMAGE_HEIGHT = 48; +export const DEFAULT_IMAGE_GAP = 10; +export const DEFAULT_THEME_COLOR = '#e8543d'; +export const BLOCK_TITLE_MAX_LINES = 2; +const DEFAULT_TITLE_IMAGE_WIDTH_RATIO = 0.52; +const DEFAULT_TITLE_IMAGE_MAX_WIDTH = 720; +const DEFAULT_TITLE_IMAGE_HEIGHT_RATIO = 0.36; +const DEFAULT_TITLE_IMAGE_TOP = 12; +const DEFAULT_TITLE_IMAGE_BOTTOM = 24; + +// ===== 布局判定 ===== + +export const isLandscape = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'landscape'; +export const isPortrait = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'portrait'; +export const isClock = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'clock'; +export const isArc = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'arc'; +export const isWing = (spec: IStorylineSpec) => normalizeLayout(spec.layout).type === 'wing'; + +export const getThemeColor = (spec: IStorylineSpec) => spec.themeColor ?? DEFAULT_THEME_COLOR; + +export const shouldShowImageBackground = (spec: IStorylineSpec) => + spec.image?.showBackground ?? !(isPortrait(spec) || isLandscape(spec)); + +// ===== 默认样式工具 ===== + +const TITLE_FONT_SCALE_ID = 'storylineTitleFontSize'; +const MARKER_FONT_SCALE_ID = 'storylineMarkerFontSize'; + +const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)); + +const getTextWeight = (text?: string) => { + if (!text) { + return 4; + } + return Math.max( + Array.from(text).reduce((sum, char) => { + if (char.trim().length === 0) { + return sum + 0.32; + } + return sum + (char.charCodeAt(0) > 255 ? 1.05 : 0.62); + }, 0), + 1 + ); +}; + +const getScaleRange = (spec: IStorylineSpec, scaleId: string, fallback: [number, number]) => { + const scales = (spec as { scales?: { id?: string; type?: string; range?: unknown }[] }).scales; + const range = scales?.find(scale => scale.id === scaleId || scale.type === scaleId)?.range; + if (Array.isArray(range) && range.length >= 2 && typeof range[0] === 'number' && typeof range[1] === 'number') { + return [Math.min(range[0], range[1]), Math.max(range[0], range[1])] as [number, number]; + } + return fallback; +}; + +const getSpecGeometry = (spec: IStorylineSpec, ctx?: LayoutContext) => { + if (ctx) { + return getRegionGeometry(ctx, spec); + } + return { + width: Math.max(Number(spec.width ?? 0), 1), + height: Math.max(Number(spec.height ?? 0), 1), + startX: 0, + startY: 0 + }; +}; + +const resolveAdaptiveFontSize = ( + spec: IStorylineSpec, + ctx: LayoutContext | undefined, + text: string | undefined, + options: { + style?: Record; + scaleId: string; + fallback: number; + range: [number, number]; + canvasRatio: number; + boxWidth?: number; + boxHeight?: number; + } +) => { + const configuredFontSize = options.style?.fontSize; + if (configuredFontSize != null) { + return Number(configuredFontSize); + } + const [minFontSize, maxFontSize] = getScaleRange(spec, options.scaleId, options.range); + const { width, height } = getSpecGeometry(spec, ctx); + const textWeight = getTextWeight(text); + const canvasSize = Math.sqrt(width * height) * options.canvasRatio; + const lengthFactor = Math.sqrt(8 / Math.max(textWeight, 4)); + const adaptiveSize = width <= 1 && height <= 1 ? options.fallback : canvasSize * lengthFactor; + const boxWidthLimit = + options.boxWidth && options.boxWidth > 0 + ? ((options.boxWidth * BLOCK_TITLE_MAX_LINES) / textWeight) * 0.96 + : Number.POSITIVE_INFINITY; + const boxHeightLimit = + options.boxHeight && options.boxHeight > 0 ? options.boxHeight / Math.max(textWeight, 1) : Number.POSITIVE_INFINITY; + return Math.floor(clamp(Math.min(adaptiveSize, boxWidthLimit, boxHeightLimit), minFontSize, maxFontSize)); +}; + +export const resolveAdaptiveLineHeight = ( + fontSize: number, + style: Record | undefined, + fallback: number, + ratio = 1.35 +) => Number(style?.lineHeight ?? Math.round((Number.isFinite(fontSize) ? fontSize : fallback) * ratio)); + +export const resolveTitleFontSize = ( + spec: IStorylineSpec, + ctx: LayoutContext | undefined, + title: string | undefined, + boxWidth: number | undefined, + fallback: number, + range: [number, number] = [16, 34] +) => + resolveAdaptiveFontSize(spec, ctx, title, { + style: spec.title?.style as Record | undefined, + scaleId: TITLE_FONT_SCALE_ID, + fallback, + range, + canvasRatio: 0.038, + boxWidth + }); + +export const resolveMarkerFontSize = ( + spec: IStorylineSpec, + ctx: LayoutContext, + marker: string | undefined, + boxHeight: number | undefined, + fallback: number, + range: [number, number] = [18, 46] +) => + resolveAdaptiveFontSize(spec, ctx, marker, { + style: spec.marker?.style as Record | undefined, + scaleId: MARKER_FONT_SCALE_ID, + fallback, + range, + canvasRatio: 0.052, + boxHeight + }); + +export const getImageBackgroundStyle = (spec: IStorylineSpec) => { + const themeColor = getThemeColor(spec); + return { + fill: { + gradient: 'linear', + x0: 0, + y0: 0, + x1: 1, + y1: 1, + stops: [ + { offset: 0, color: '#ffffff' }, + { offset: 0.58, color: withAlpha(themeColor, 0.12) }, + { offset: 1, color: withAlpha(themeColor, 0.32) } + ] + }, + stroke: withAlpha(themeColor, 0.78), + lineWidth: 2, + shadowColor: withAlpha(themeColor, 0.18), + shadowBlur: 10, + shadowOffsetX: 0, + shadowOffsetY: 4 + }; +}; + +export const getTitleImageSize = ( + spec: IStorylineSpec, + width: number, + height: number, + options?: { widthRatio?: number; maxWidth?: number; heightRatio?: number } +) => { + const defaultWidth = Math.min( + Math.max(width * (options?.widthRatio ?? DEFAULT_TITLE_IMAGE_WIDTH_RATIO), 1), + options?.maxWidth ?? DEFAULT_TITLE_IMAGE_MAX_WIDTH + ); + const imageWidth = Math.max(Number(spec.titleImage?.width ?? defaultWidth), 1); + const imageHeight = Math.max( + Number( + spec.titleImage?.height ?? + Math.min(height, imageWidth * (options?.heightRatio ?? DEFAULT_TITLE_IMAGE_HEIGHT_RATIO)) + ), + 1 + ); + return { width: imageWidth, height: imageHeight }; +}; + +export const getTitleImageReservedHeight = ( + spec: IStorylineSpec, + width: number, + height: number, + options?: { y?: number; widthRatio?: number; maxWidth?: number; heightRatio?: number; bottom?: number } +) => { + if (!spec.titleImage?.image || spec.titleImage.visible === false) { + return 0; + } + const size = getTitleImageSize(spec, width, height, options); + return Math.ceil( + (options?.y ?? DEFAULT_TITLE_IMAGE_TOP) + size.height + (options?.bottom ?? DEFAULT_TITLE_IMAGE_BOTTOM) + ); +}; + +export const getChartGeometry = (ctx: LayoutContext, spec?: { width?: number; height?: number }) => { + const chartRect = ctx.chart?.getLayoutRect?.(); + const bounds = ctx.getLayoutBounds?.(); + const width = Math.max(chartRect?.width ?? bounds?.width?.() ?? spec?.width ?? 0, 1); + const height = Math.max(chartRect?.height ?? bounds?.height?.() ?? spec?.height ?? 0, 1); + return { width, height, startX: 0, startY: 0 }; +}; + +export const buildTopTitleImageMark = ( + spec: IStorylineSpec, + options?: { y?: number; widthRatio?: number; maxWidth?: number; heightRatio?: number } +): ICustomMarkSpec<'image'> | null => { + if (!spec.titleImage?.image || spec.titleImage.visible === false) { + return null; + } + return { + type: 'image', + name: 'storyline-title-image', + interactive: false, + zIndex: LayoutZIndex.Mark + 8, + ...spec.titleImage, + style: { + x: (_d: unknown, ctx: LayoutContext) => { + const { width, height, startX } = getChartGeometry(ctx, spec); + const size = getTitleImageSize(spec, width, height, options); + return startX + (width - size.width) / 2; + }, + y: (_d: unknown, ctx: LayoutContext) => + getChartGeometry(ctx, spec).startY + (options?.y ?? DEFAULT_TITLE_IMAGE_TOP), + width: (_d: unknown, ctx: LayoutContext) => { + const { width, height } = getChartGeometry(ctx, spec); + return getTitleImageSize(spec, width, height, options).width; + }, + height: (_d: unknown, ctx: LayoutContext) => { + const { width, height } = getChartGeometry(ctx, spec); + return getTitleImageSize(spec, width, height, options).height; + }, + image: spec.titleImage.image, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'contain', + imagePosition: 'center', + ...spec.titleImage.style + } + } as ICustomMarkSpec<'image'>; +}; + +// ===== 颜色工具 ===== + +/** + * 给颜色(#hex / rgb / rgba / hsl / 颜色关键字)追加/替换 alpha 通道,返回 rgba(...) 字符串 + */ +export const withAlpha = (color: string, alpha: number): string => { + const safeAlpha = Math.max(0, Math.min(1, alpha)); + if (!color) { + return `rgba(0, 0, 0, ${safeAlpha})`; + } + const trimmed = color.trim(); + if (trimmed.startsWith('#')) { + let hex = trimmed.slice(1); + if (hex.length === 3 || hex.length === 4) { + hex = hex + .split('') + .map(ch => ch + ch) + .join(''); + } + if (hex.length === 6 || hex.length === 8) { + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${safeAlpha})`; + } + } + const rgbMatch = trimmed.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i); + if (rgbMatch) { + return `rgba(${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}, ${safeAlpha})`; + } + return trimmed; +}; + +// ===== 块宽度解析 ===== + +export const resolveBlockWidth = (spec: IStorylineSpec, viewWidth: number) => { + if (spec.block?.width) { + return spec.block.width; + } + const ratio = spec.block?.widthRatio ?? DEFAULT_BLOCK_WIDTH_RATIO; + const minWidth = spec.block?.minWidth ?? DEFAULT_BLOCK_WIDTH; + const maxWidth = spec.block?.maxWidth ?? Math.max(minWidth, 320); + return Math.max(minWidth, Math.min(maxWidth, Math.round(viewWidth * ratio))); +}; + +// ===== 容器几何信息(chart region rect)===== + +export const getRegionGeometry = (ctx: LayoutContext, spec?: { width?: number; height?: number }) => { + const region = ctx.chart?.getAllRegions?.()?.[0]; + const regionRect = region?.getLayoutRect?.(); + const regionStart = region?.getLayoutStartPoint?.(); + const chartRect = ctx.chart?.getLayoutRect?.(); + const bounds = ctx.getLayoutBounds?.(); + const width = Math.max(regionRect?.width ?? chartRect?.width ?? bounds?.width?.() ?? spec?.width ?? 0, 1); + const height = Math.max(regionRect?.height ?? chartRect?.height ?? bounds?.height?.() ?? spec?.height ?? 0, 1); + return { + width, + height, + startX: regionStart?.x ?? 0, + startY: regionStart?.y ?? 0 + }; +}; + +// ===== 布局计算(layout.ts 的封装,附加 startX/startY 平移)===== + +export const getLayout = (spec: IStorylineSpec, ctx: LayoutContext): StorylineLayoutResult => { + const { width, height, startX, startY } = getRegionGeometry(ctx, spec); + let blockWidth = resolveBlockWidth(spec, width); + let blockHeight = spec.block?.height ?? (isLandscape(spec) ? 320 : DEFAULT_BLOCK_HEIGHT); + // landscape:图片间距固定 40,根据 block 数量自适应单个 image 宽度 + if (isLandscape(spec) && !spec.block?.width) { + const count = spec.data?.length ?? 0; + if (count > 0) { + const padding = normalizePadding(spec.block?.padding); + const innerWidth = Math.max(width - padding.left - padding.right, 1); + const LANDSCAPE_IMAGE_GAP = 40; + const LANDSCAPE_IMAGE_MIN_WIDTH = 80; + const totalGap = LANDSCAPE_IMAGE_GAP * Math.max(count - 1, 0); + const adaptive = (innerWidth - totalGap) / count; + blockWidth = Math.max(LANDSCAPE_IMAGE_MIN_WIDTH, Math.floor(adaptive)); + } + } + // portrait:每个 block 在垂直方向需要容纳 image + text,整体根据 region 高度均分 + // blockHeight = regionHeight / (count + 1)(即每个 block 的"槽位"高度),后续 portrait.ts 中: + // imageHeight = blockHeight * 0.6 + // contentHeight = blockHeight + if (isPortrait(spec) && !spec.block?.height) { + const count = spec.data?.length ?? 0; + if (count > 0) { + const layoutPadding = typeof spec.layout === 'object' ? spec.layout.padding : undefined; + const padding = normalizePadding(layoutPadding ?? spec.block?.padding); + const innerHeight = Math.max(height - padding.top - padding.bottom, 1); + blockHeight = Math.max(120, Math.floor(innerHeight / (count + 1))); + } + } + const result = computeStorylineLayout(spec.data ?? [], { + layout: spec.layout, + viewBox: { width, height }, + block: { + width: blockWidth, + height: blockHeight + }, + padding: spec.block?.padding, + lineDistance: spec.line?.distance + }); + if (!startX && !startY) { + return result; + } + return { + ...result, + blocks: result.blocks.map(block => ({ + ...block, + x: block.x + startX, + y: block.y + startY, + center: { + x: block.center.x + startX, + y: block.center.y + startY + } + })), + links: result.links.map(link => ({ + ...link, + start: { x: link.start.x + startX, y: link.start.y + startY }, + end: { x: link.end.x + startX, y: link.end.y + startY }, + points: link.points.map(point => ({ x: point.x + startX, y: point.y + startY })) + })) + }; +}; + +// ===== 文本 / 图像通用工具 ===== + +export const buildPlainContent = (contentText: string[]) => contentText.join('\n'); + +export const getBlockTitleLineCount = ( + title: string | undefined, + boxWidth: number | undefined, + fontSize: number | undefined +) => { + if (!title) { + return 0; + } + if (!boxWidth || boxWidth <= 0 || !fontSize || fontSize <= 0) { + return 1; + } + const estimatedWidth = getTextWeight(title) * fontSize; + return Math.max(1, Math.min(BLOCK_TITLE_MAX_LINES, Math.ceil(estimatedWidth / Math.max(boxWidth * 0.96, 1)))); +}; + +export const getBlockTitleHeight = (lineHeight: number, title?: string, boxWidth?: number, fontSize?: number) => + lineHeight * getBlockTitleLineCount(title, boxWidth, fontSize); + +export const omitImageLayoutSpec = (imageSpec: IStorylineSpec['image']) => { + if (!imageSpec) { + return {}; + } + const { width: _width, height: _height, position: _position, gap: _gap, ...rest } = imageSpec; + return rest; +}; + +// ===== 默认 image / text 盒计算(用于通用 block)===== + +export const getImageBox = ( + position: StorylineImagePosition, + blockWidth: number, + blockHeight: number, + padding: ReturnType, + width: number, + height: number, + _gap: number, + visible: boolean +) => { + if (!visible) { + return { x: padding.left, y: padding.top, width: 0, height: 0 }; + } + switch (position) { + case 'left': + return { x: padding.left, y: (blockHeight - height) / 2, width, height }; + case 'right': + return { x: blockWidth - padding.right - width, y: (blockHeight - height) / 2, width, height }; + case 'bottom': + return { x: (blockWidth - width) / 2, y: blockHeight - padding.bottom - height, width, height }; + case 'top': + default: + return { x: (blockWidth - width) / 2, y: padding.top, width, height }; + } +}; + +export const getTextBox = ( + position: StorylineImagePosition, + blockWidth: number, + blockHeight: number, + padding: ReturnType, + imageWidth: number, + imageHeight: number, + imageGap: number, + hasImage: boolean +) => { + if (!hasImage) { + return { + x: padding.left, + y: padding.top, + width: blockWidth - padding.left - padding.right, + height: blockHeight - padding.top - padding.bottom + }; + } + switch (position) { + case 'left': + return { + x: padding.left + imageWidth + imageGap, + y: padding.top, + width: blockWidth - padding.left - padding.right - imageWidth - imageGap, + height: blockHeight - padding.top - padding.bottom + }; + case 'right': + return { + x: padding.left, + y: padding.top, + width: blockWidth - padding.left - padding.right - imageWidth - imageGap, + height: blockHeight - padding.top - padding.bottom + }; + case 'bottom': + return { + x: padding.left, + y: padding.top, + width: blockWidth - padding.left - padding.right, + height: blockHeight - padding.top - padding.bottom - imageHeight - imageGap + }; + case 'top': + default: + return { + x: padding.left, + y: padding.top + imageHeight + imageGap, + width: blockWidth - padding.left - padding.right, + height: blockHeight - padding.top - padding.bottom - imageHeight - imageGap + }; + } +}; + +// ===== Catmull-Rom 平滑曲线 ===== + +/** + * 用 Catmull-Rom 转 cubic Bezier 生成平滑曲线 path(贯穿所有点)。 + */ +export const buildSmoothCurvePath = (points: StorylinePoint[]): string => { + if (points.length < 2) { + return ''; + } + if (points.length === 2) { + return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`; + } + let d = `M ${points[0].x} ${points[0].y}`; + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[i - 1] ?? points[i]; + const p1 = points[i]; + const p2 = points[i + 1]; + const p3 = points[i + 2] ?? p2; + const c1x = p1.x + (p2.x - p0.x) / 6; + const c1y = p1.y + (p2.y - p0.y) / 6; + const c2x = p2.x - (p3.x - p1.x) / 6; + const c2y = p2.y - (p3.y - p1.y) / 6; + d += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`; + } + return d; +}; + +// 重导出常用的 layout helper(避免外部再 import layout.ts) +export { normalizeLayout, normalizePadding }; +export type { IStorylineBlock, ICustomMarkSpec, StorylinePoint }; diff --git a/packages/vchart-extension/src/charts/storyline/layouts/default.ts b/packages/vchart-extension/src/charts/storyline/layouts/default.ts new file mode 100644 index 0000000000..af22424861 --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/layouts/default.ts @@ -0,0 +1,297 @@ +import type { IExtensionGroupMarkSpec } from '@visactor/vchart'; +import { LayoutZIndex } from '@visactor/vchart'; +import type { IStorylineBlock, IStorylineSpec, StorylineLineType } from '../interface'; +import { + type ICustomMarkSpec, + type LayoutContext, + type StorylinePoint, + DEFAULT_BLOCK_HEIGHT, + DEFAULT_IMAGE_WIDTH, + DEFAULT_IMAGE_HEIGHT, + DEFAULT_IMAGE_GAP, + BLOCK_TITLE_MAX_LINES, + buildPlainContent, + getBlockTitleHeight, + getImageBackgroundStyle, + getImageBox, + getLayout, + getTextBox, + normalizePadding, + omitImageLayoutSpec, + resolveBlockWidth, + resolveAdaptiveLineHeight, + resolveTitleFontSize, + shouldShowImageBackground +} from './common'; + +/** + * 默认布局:rect block(image + title + content) + 普通 link mark。 + */ + +export const buildDefaultLineMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + if (spec.line?.visible === false || (spec.data?.length ?? 0) <= 1) { + return null; + } + + return { + type: 'group' as any, + name: 'storyline-links', + zIndex: LayoutZIndex.Mark, + children: (spec.data ?? []).slice(1).map((_, index) => { + const { style = {}, type = 'line', showArrow = false, arrowSize = 8, ...rest } = spec.line ?? {}; + return { + type: 'path', + name: `storyline-link-${index}`, + interactive: false, + ...rest, + style: { + stroke: '#8a94a6', + lineWidth: 1.5, + fill: 'transparent', + fillOpacity: 0, + ...style, + path: (_datum: unknown, ctx: LayoutContext) => { + const link = getLayout(spec, ctx).links[index]; + if (!link) { + return ''; + } + return buildLinkPath(link.points, type, showArrow, arrowSize); + } + } + } as ICustomMarkSpec<'path'>; + }) + }; +}; + +const getDefaultBlockMetrics = (spec: IStorylineSpec, ctx: LayoutContext, index: number) => { + const block = getLayout(spec, ctx).blocks[index]; + const padding = normalizePadding(spec.block?.padding ?? 12); + const imagePosition = spec.image?.position ?? 'top'; + const imageWidth = spec.image?.width ?? DEFAULT_IMAGE_WIDTH; + const imageHeight = spec.image?.height ?? DEFAULT_IMAGE_HEIGHT; + const imageGap = spec.image?.gap ?? DEFAULT_IMAGE_GAP; + const hasImage = !!spec.data?.[index]?.image; + const blockWidth = block?.width ?? resolveBlockWidth(spec, 0); + const blockHeight = block?.height ?? spec.block?.height ?? DEFAULT_BLOCK_HEIGHT; + const imageBox = getImageBox( + imagePosition, + blockWidth, + blockHeight, + padding, + imageWidth, + imageHeight, + imageGap, + hasImage + ); + const textBox = getTextBox( + imagePosition, + blockWidth, + blockHeight, + padding, + imageWidth, + imageHeight, + imageGap, + hasImage + ); + const titleFontSize = resolveTitleFontSize(spec, ctx, spec.data?.[index]?.title, textBox.width, 18, [8, 28]); + const titleLineHeight = resolveAdaptiveLineHeight(titleFontSize, spec.title?.style as any, Math.round(18 * 1.35)); + const titleHeight = getBlockTitleHeight(titleLineHeight, spec.data?.[index]?.title, textBox.width, titleFontSize); + const contentGap = spec.data?.[index]?.title ? 8 : 0; + + return { + block: { + width: blockWidth, + height: blockHeight + }, + titleFontSize, + titleLineHeight, + titleHeight, + imageBox, + textBox, + contentBox: { + y: textBox.y + titleHeight + contentGap, + height: Math.max(0, textBox.height - titleHeight - contentGap) + } + }; +}; + +export const buildDefaultBlockMark = ( + spec: IStorylineSpec, + block: IStorylineBlock, + index: number +): IExtensionGroupMarkSpec => { + const hasImage = !!block.image; + const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; + + return { + type: 'group' as any, + id: `storyline-block-${block.id ?? index}`, + name: `storyline-block-${index}`, + zIndex: LayoutZIndex.Mark + 1, + style: { + x: (_datum: unknown, ctx: LayoutContext) => getLayout(spec, ctx).blocks[index]?.x ?? 0, + y: (_datum: unknown, ctx: LayoutContext) => getLayout(spec, ctx).blocks[index]?.y ?? 0, + width: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).block.width, + height: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).block.height + }, + children: [ + spec.block?.showBackground === true + ? ({ + type: 'rect', + name: `storyline-block-bg-${index}`, + interactive: false, + style: { + x: 0, + y: 0, + width: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).block.width, + height: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).block.height, + cornerRadius: 8, + fill: '#ffffff', + stroke: '#d7dce5', + lineWidth: 1, + shadowBlur: 6, + shadowColor: 'rgba(0, 0, 0, 0.08)', + ...spec.block?.style + } + } as ICustomMarkSpec<'rect'>) + : null, + shouldShowImageBackground(spec) + ? ({ + type: 'rect', + name: `storyline-block-image-bg-${index}`, + interactive: false, + style: { + x: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).imageBox.x, + y: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).imageBox.y, + width: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).imageBox.width, + height: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).imageBox.height, + cornerRadius: 8, + ...getImageBackgroundStyle(spec), + ...spec.block?.style + } + } as ICustomMarkSpec<'rect'>) + : null, + hasImage + ? ({ + type: 'image', + name: `storyline-block-image-${index}`, + interactive: false, + ...omitImageLayoutSpec(spec.image), + style: { + x: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).imageBox.x, + y: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).imageBox.y, + width: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).imageBox.width, + height: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).imageBox.height, + image: block.image, + ...spec.image?.style + } + } as ICustomMarkSpec<'image'>) + : null, + block.title + ? ({ + type: 'text', + name: `storyline-block-title-${index}`, + interactive: false, + ...spec.title, + style: { + x: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).textBox.x, + y: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).textBox.y, + text: block.title, + height: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).titleHeight, + heightLimit: (_datum: unknown, ctx: LayoutContext) => + getDefaultBlockMetrics(spec, ctx, index).titleLineHeight * BLOCK_TITLE_MAX_LINES, + lineClamp: BLOCK_TITLE_MAX_LINES, + fontSize: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).titleFontSize, + lineHeight: (_datum: unknown, ctx: LayoutContext) => + getDefaultBlockMetrics(spec, ctx, index).titleLineHeight, + fontWeight: 'bold', + fill: '#1f2430', + stroke: '#fff', + lineWidth: 5, + lineJoin: 'round', + textAlign: 'left', + textBaseline: 'top', + whiteSpace: 'normal', + wordBreak: 'break-word', + ellipsis: '...', + ...spec.title?.style + } + } as ICustomMarkSpec<'text'>) + : null, + contentText.length + ? ({ + type: 'text', + name: `storyline-block-content-${index}`, + interactive: false, + ...spec.content, + style: { + x: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).textBox.x, + y: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).contentBox.y, + width: (_datum: unknown, ctx: LayoutContext) => getDefaultBlockMetrics(spec, ctx, index).textBox.width, + height: (_datum: unknown, ctx: LayoutContext) => + getDefaultBlockMetrics(spec, ctx, index).contentBox.height, + text: buildPlainContent(contentText), + maxLineWidth: (_datum: unknown, ctx: LayoutContext) => + getDefaultBlockMetrics(spec, ctx, index).textBox.width, + heightLimit: (_datum: unknown, ctx: LayoutContext) => + getDefaultBlockMetrics(spec, ctx, index).contentBox.height, + fontSize: 16, + lineHeight: 23, + textAlign: 'left', + textBaseline: 'top', + whiteSpace: 'normal', + wordBreak: 'break-word', + ellipsis: '...', + fill: '#596173', + ...spec.content?.style + } + } as ICustomMarkSpec<'text'>) + : null + ].filter(Boolean) as ICustomMarkSpec[] + }; +}; + +const buildLinkPath = ( + points: StorylinePoint[], + type: StorylineLineType, + showArrow: boolean, + arrowSize: number +): string => { + const start = points[0]; + const end = points[points.length - 1]; + if (!start || !end) { + return ''; + } + let path: string; + if (type === 'curve') { + const dx = end.x - start.x; + const dy = end.y - start.y; + const curve = Math.max(Math.min(Math.sqrt(dx * dx + dy * dy) * 0.22, 80), 24); + path = + `M ${start.x} ${start.y} ` + + `C ${start.x + dx / 2} ${start.y - curve} ${end.x - dx / 2} ${end.y + curve} ${end.x} ${end.y}`; + } else if (type === 'polyline') { + const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }; + path = `M ${start.x} ${start.y} L ${mid.x} ${start.y} L ${mid.x} ${end.y} L ${end.x} ${end.y}`; + } else { + path = `M ${start.x} ${start.y} L ${end.x} ${end.y}`; + } + + if (!showArrow) { + return path; + } + return `${path} ${buildArrowPath(start, end, arrowSize)}`; +}; + +const buildArrowPath = (start: StorylinePoint, end: StorylinePoint, size: number) => { + const angle = Math.atan2(end.y - start.y, end.x - start.x); + const left = { + x: end.x - Math.cos(angle - Math.PI / 6) * size, + y: end.y - Math.sin(angle - Math.PI / 6) * size + }; + const right = { + x: end.x - Math.cos(angle + Math.PI / 6) * size, + y: end.y - Math.sin(angle + Math.PI / 6) * size + }; + return `M ${left.x} ${left.y} L ${end.x} ${end.y} L ${right.x} ${right.y}`; +}; diff --git a/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts b/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts new file mode 100644 index 0000000000..ee73e22ec7 --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/layouts/landscape.ts @@ -0,0 +1,402 @@ +import type { IExtensionGroupMarkSpec } from '@visactor/vchart'; +import { LayoutZIndex } from '@visactor/vchart'; +import type { IStorylineBlock, IStorylineSpec } from '../interface'; +import { + type ICustomMarkSpec, + type LayoutContext, + type StorylinePoint, + DEFAULT_BLOCK_HEIGHT, + BLOCK_TITLE_MAX_LINES, + buildPlainContent, + buildSmoothCurvePath, + getBlockTitleHeight, + getImageBackgroundStyle, + getLayout, + getRegionGeometry, + getThemeColor, + normalizePadding, + omitImageLayoutSpec, + resolveAdaptiveLineHeight, + resolveBlockWidth, + resolveTitleFontSize, + shouldShowImageBackground +} from './common'; + +// landscape 布局下,image rect 与 text rect 分离展示 +const LANDSCAPE_IMAGE_HEIGHT_RATIO = 0.42; +const LANDSCAPE_CONNECTOR_GAP = 8; +const LANDSCAPE_CONNECTOR_X_RATIO = 0.2; // 引导线 x 位于 image 左侧 20% 处 +const LANDSCAPE_TEXT_GAP_FROM_CONNECTOR = 12; // 文字距离引导线的水平间距 +const LANDSCAPE_TITLE_LINE_HEIGHT = 34; +const LANDSCAPE_CONTENT_LINE_HEIGHT = 23; +const LANDSCAPE_CONTENT_FONT_SIZE = 16; +const LANDSCAPE_TITLE_TO_CONTENT_GAP = 4; +const LANDSCAPE_BODY_MARGIN = 8; +const LANDSCAPE_IMAGE_BAND_RATIO = 0.28; +const LANDSCAPE_IMAGE_BAND_MAX_RATIO = 0.42; + +/** + * 计算第 index 个 block 在 landscape 布局下的 image 中心点(含 stagger 错落偏移)。 + */ +const getLandscapeImageCenter = (spec: IStorylineSpec, ctx: LayoutContext, index: number): StorylinePoint | null => { + const lb = getLayout(spec, ctx).blocks[index]; + if (!lb) { + return null; + } + const m = getLandscapeMetrics(spec, lb.width, lb.height, index, ctx); + return { + x: lb.x + m.imageBox.x + m.imageBox.width / 2, + y: m.bodyOriginY + m.imageBox.y + m.imageBox.height / 2 + }; +}; + +/** + * landscape 下绘制一条贯穿所有 image 中心的平滑虚线曲线,并在每个节点位置画 symbol, + * 颜色跟随主题色。 + */ +export const buildLandscapeConnectingCurve = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + const themeColor = getThemeColor(spec); + const lineStyle = spec.line?.style ?? {}; + const count = spec.data?.length ?? 0; + const symbolSize = 14; + const symbolChildren: ICustomMarkSpec<'symbol'>[] = []; + for (let i = 0; i < count; i++) { + const idx = i; + symbolChildren.push({ + type: 'symbol', + name: `storyline-landscape-curve-symbol-${idx}`, + interactive: false, + style: { + symbolType: 'circle', + size: symbolSize, + fill: themeColor, + x: (_d: unknown, ctx: LayoutContext) => getLandscapeImageCenter(spec, ctx, idx)?.x ?? 0, + y: (_d: unknown, ctx: LayoutContext) => getLandscapeImageCenter(spec, ctx, idx)?.y ?? 0 + } + } as ICustomMarkSpec<'symbol'>); + } + return { + type: 'group' as any, + name: 'storyline-landscape-curve', + zIndex: LayoutZIndex.Mark + 2, + children: [ + { + type: 'path', + name: 'storyline-landscape-curve-path', + interactive: false, + style: { + stroke: (lineStyle as any).stroke ?? themeColor, + lineWidth: (lineStyle as any).lineWidth ?? 4, + lineDash: (lineStyle as any).lineDash ?? [6, 5], + lineCap: 'round', + fill: 'transparent', + fillOpacity: 0, + path: (_d: unknown, ctx: LayoutContext) => { + const points: StorylinePoint[] = []; + for (let i = 0; i < count; i++) { + const center = getLandscapeImageCenter(spec, ctx, i); + if (center) { + points.push(center); + } + } + return buildSmoothCurvePath(points); + } + } + } as ICustomMarkSpec<'path'>, + ...symbolChildren + ] + }; +}; + +/** + * landscape 布局下,每个 block 拆分为 image rect 与 text rect 两个独立卡片, + * 中间用主题色虚线箭头连接;title+content 在 image 上方/下方交替错落摆放。 + */ +const getLandscapeMetrics = ( + spec: IStorylineSpec, + blockWidth: number, + blockHeight: number, + index: number, + ctx: LayoutContext +) => { + const padding = normalizePadding(spec.block?.padding ?? 12); + const titleFontSize = resolveTitleFontSize(spec, ctx, spec.data?.[index]?.title, blockWidth, 26, [8, 34]); + const titleLineHeight = resolveAdaptiveLineHeight( + titleFontSize, + spec.title?.style as any, + LANDSCAPE_TITLE_LINE_HEIGHT + ); + const contentFontSize = Number((spec.content?.style as any)?.fontSize ?? LANDSCAPE_CONTENT_FONT_SIZE); + const contentLineHeight = Number((spec.content?.style as any)?.lineHeight ?? LANDSCAPE_CONTENT_LINE_HEIGHT); + const { width: regionWidth } = getRegionGeometry(ctx, spec); + const blockCount = Math.max(spec.data?.length ?? 1, 1); + const textWidth = Math.max(regionWidth / blockCount, 1); + const titleHeight = getBlockTitleHeight(titleLineHeight, spec.data?.[index]?.title, textWidth, titleFontSize); + + const rawImageHeight = Math.max( + spec.image?.height ?? Math.round(blockHeight * LANDSCAPE_IMAGE_HEIGHT_RATIO), + titleLineHeight + padding.top + padding.bottom + ); + const titleToContentGap = LANDSCAPE_TITLE_TO_CONTENT_GAP; + const minTextBandHeight = titleHeight + titleToContentGap + contentLineHeight * 2; + const body = getLandscapeBodyGeometry(ctx, spec, rawImageHeight, blockHeight, minTextBandHeight); + const imageHeight = body.imageHeight; + const textBandHeight = body.textBandHeight; + const contentHeight = Math.max(0, textBandHeight - titleHeight - titleToContentGap); + const textHeight = titleHeight + titleToContentGap + contentHeight; + + const textOnTop = index % 2 === 0; + + let textBox: { x: number; y: number; width: number; height: number }; + let contentBox: { x: number; y: number; width: number; height: number }; + let imageBox: { x: number; y: number; width: number; height: number }; + let connector: { x1: number; y1: number; x2: number; y2: number }; + let groupTop: number; + let groupHeight: number; + + const imageX = 0; + const connectorX = imageX + blockWidth * LANDSCAPE_CONNECTOR_X_RATIO; + const textX = connectorX + LANDSCAPE_TEXT_GAP_FROM_CONNECTOR; + const waveOffset = (textOnTop ? -1 : 1) * body.waveAmplitude; + const imageY = body.imageBandTop + body.imageBandHeight / 2 + waveOffset - imageHeight / 2; + + if (textOnTop) { + const textY = body.upperTextTop; + const connectorY1 = Math.max(imageY - LANDSCAPE_CONNECTOR_GAP, body.upperTextTop); + const connectorY2 = textY + Math.max(titleHeight, titleLineHeight) / 2; + + imageBox = { x: imageX, y: imageY, width: blockWidth, height: imageHeight }; + textBox = { x: textX, y: textY, width: textWidth, height: textHeight }; + contentBox = { + x: textX, + y: textY + titleHeight + titleToContentGap, + width: textWidth, + height: contentHeight + }; + connector = { x1: connectorX, y1: connectorY1, x2: connectorX, y2: connectorY2 }; + groupTop = 0; + groupHeight = body.height; + } else { + const textY = body.lowerTextTop; + const connectorY1 = Math.min(imageY + imageHeight + LANDSCAPE_CONNECTOR_GAP, body.height); + const connectorY2 = textY + textHeight; + + imageBox = { x: imageX, y: imageY, width: blockWidth, height: imageHeight }; + textBox = { x: textX, y: textY, width: textWidth, height: textHeight }; + contentBox = { + x: textX, + y: textY + titleHeight + titleToContentGap, + width: textWidth, + height: contentHeight + }; + connector = { x1: connectorX, y1: connectorY1, x2: connectorX, y2: connectorY2 }; + groupTop = 0; + groupHeight = body.height; + } + + return { + padding, + titleFontSize, + titleLineHeight, + titleHeight, + contentFontSize, + contentLineHeight, + contentHeight, + minTextBandHeight, + bodyOriginY: body.originY, + blockWidth: Math.max(blockWidth, textX + textWidth), + imageBox, + textBox, + contentBox, + connector, + textOnTop, + groupTop, + groupHeight + }; +}; + +const getLandscapeBodyGeometry = ( + ctx: LayoutContext, + spec: IStorylineSpec, + rawImageHeight: number, + blockHeight: number, + minTextBandHeight: number +) => { + const region = getRegionGeometry(ctx, spec); + const margin = Math.min(LANDSCAPE_BODY_MARGIN, Math.max(region.height * 0.04, 0)); + const height = Math.max(region.height - margin * 2, 1); + const textBandReserve = Math.min(minTextBandHeight, Math.max((height - 48) / 2, 0)); + const maxImageBandHeight = Math.max(height - textBandReserve * 2, height * 0.18); + const imageBandHeight = Math.min( + Math.max(rawImageHeight + 24, height * LANDSCAPE_IMAGE_BAND_RATIO), + Math.max(1, Math.min(maxImageBandHeight, height * LANDSCAPE_IMAGE_BAND_MAX_RATIO)) + ); + const imageHeight = Math.min(rawImageHeight, Math.max(imageBandHeight - 16, 1)); + const textBandHeight = Math.max((height - imageBandHeight) / 2, 1); + const imageBandTop = textBandHeight; + + return { + originY: region.startY + margin, + height, + textBandHeight, + imageBandTop, + imageBandHeight, + imageHeight, + upperTextTop: 0, + lowerTextTop: imageBandTop + imageBandHeight, + waveAmplitude: Math.min(imageBandHeight * 0.18, blockHeight * 0.1) + }; +}; + +export const buildLandscapeBlockMark = ( + spec: IStorylineSpec, + block: IStorylineBlock, + index: number +): IExtensionGroupMarkSpec => { + const hasImage = !!block.image; + const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; + + const getMetrics = (ctx: LayoutContext) => { + const layoutBlock = getLayout(spec, ctx).blocks[index]; + const w = layoutBlock?.width ?? resolveBlockWidth(spec, 0); + const h = layoutBlock?.height ?? spec.block?.height ?? DEFAULT_BLOCK_HEIGHT; + return getLandscapeMetrics(spec, w, h, index, ctx); + }; + + const blockStyle = spec.block?.style ?? {}; + const lineStyle = spec.line?.style ?? {}; + const themeColor = getThemeColor(spec); + const connectorStroke = (lineStyle as any).stroke ?? themeColor; + const connectorLineWidth = (lineStyle as any).lineWidth ?? 2; + const connectorDash = (lineStyle as any).lineDash ?? [4, 4]; + + return { + type: 'group' as any, + id: `storyline-block-${block.id ?? index}`, + name: `storyline-block-${index}`, + zIndex: LayoutZIndex.Mark + 1, + style: { + x: (_d: unknown, ctx: LayoutContext) => { + const lb = getLayout(spec, ctx).blocks[index]; + return lb?.x ?? 0; + }, + y: (_d: unknown, ctx: LayoutContext) => { + const m = getMetrics(ctx); + return m.bodyOriginY; + }, + width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).blockWidth, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).groupHeight + }, + children: [ + shouldShowImageBackground(spec) + ? ({ + type: 'rect', + name: `storyline-block-image-bg-${index}`, + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.y, + width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.width, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.height, + cornerRadius: 8, + ...getImageBackgroundStyle(spec), + ...blockStyle + } + } as ICustomMarkSpec<'rect'>) + : null, + hasImage + ? ({ + type: 'image', + name: `storyline-block-image-${index}`, + interactive: false, + ...omitImageLayoutSpec(spec.image), + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.y, + width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.width, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.height, + image: block.image, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'contain', + imagePosition: 'center', + ...spec.image?.style + } + } as ICustomMarkSpec<'image'>) + : null, + { + type: 'path', + name: `storyline-block-connector-${index}`, + interactive: false, + style: { + stroke: connectorStroke, + lineWidth: connectorLineWidth, + lineDash: connectorDash, + fill: false, + path: (_d: unknown, ctx: LayoutContext) => { + const m = getMetrics(ctx); + const x = m.connector.x1; + const y0 = m.connector.y1; + const y1 = m.connector.y2; + return `M ${x} ${y0} L ${x} ${y1}`; + } + } + } as ICustomMarkSpec<'path'>, + block.title + ? ({ + type: 'text', + name: `storyline-block-title-${index}`, + interactive: false, + ...spec.title, + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textBox.y, + text: block.title, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleHeight, + heightLimit: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleLineHeight * BLOCK_TITLE_MAX_LINES, + lineClamp: BLOCK_TITLE_MAX_LINES, + fontSize: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleFontSize, + lineHeight: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleLineHeight, + fontWeight: 'bold', + fill: '#1f2430', + stroke: '#fff', + lineWidth: 5, + lineJoin: 'round', + textAlign: 'left', + textBaseline: 'top', + whiteSpace: 'normal', + wordBreak: 'break-word', + ellipsis: '...', + ...spec.title?.style + } + } as ICustomMarkSpec<'text'>) + : null, + contentText.length + ? ({ + type: 'text', + name: `storyline-block-content-${index}`, + interactive: false, + ...spec.content, + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.y, + width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.width, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.height, + maxLineWidth: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.width, + heightLimit: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.height, + text: buildPlainContent(contentText), + fontSize: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentFontSize, + lineHeight: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentLineHeight, + textAlign: 'left', + textBaseline: 'top', + whiteSpace: 'normal', + wordBreak: 'break-word', + ellipsis: '...', + fill: '#596173', + ...spec.content?.style + } + } as ICustomMarkSpec<'text'>) + : null + ].filter(Boolean) as ICustomMarkSpec[] + }; +}; diff --git a/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts b/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts new file mode 100644 index 0000000000..e67466114c --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/layouts/portrait.ts @@ -0,0 +1,410 @@ +import type { IExtensionGroupMarkSpec } from '@visactor/vchart'; +import { LayoutZIndex } from '@visactor/vchart'; +import type { IStorylineBlock, IStorylineSpec } from '../interface'; +import { + type ICustomMarkSpec, + type LayoutContext, + DEFAULT_BLOCK_HEIGHT, + BLOCK_TITLE_MAX_LINES, + buildPlainContent, + getBlockTitleHeight, + getImageBackgroundStyle, + getLayout, + omitImageLayoutSpec, + resolveAdaptiveLineHeight, + resolveMarkerFontSize, + resolveBlockWidth, + resolveTitleFontSize, + shouldShowImageBackground, + withAlpha +} from './common'; + +// portrait 布局:中轴 rect + 左右交替的 image + image 下方 title/content +// 中轴默认加宽,便于在轴上叠放 block.marker 时间节点文字(纵向逐字排列) +const PORTRAIT_AXIS_WIDTH = 96; +const PORTRAIT_AXIS_PADDING = 120; // 中轴上下两端的留白 +// marker 时间节点文字的默认样式(fontSize 30、白色字、贴轴对应一侧边缘) +const PORTRAIT_MARKER_FONT_SIZE = 34; +const PORTRAIT_MARKER_LINE_HEIGHT = 24; +const PORTRAIT_MARKER_AXIS_PADDING = 6; // marker 距离轴边缘的水平内边距 +// image 默认尺寸的占比规则(基于 region 平均槽位): +// - image 高度 = slotHeight * 0.6 +// - content 高度 = slotHeight * (0.6 + 0.4) +// 其中 slotHeight = regionHeight / (blockCount + 1),由 getLayout 计算。 +export const PORTRAIT_IMAGE_HEIGHT_RATIO = 0.6; +export const PORTRAIT_CONTENT_HEIGHT_RATIO = 1.25; +const PORTRAIT_IMAGE_GAP_FROM_AXIS = 24; // image 与中轴之间的水平间距 +const PORTRAIT_SHADOW_OFFSET_X = 24; // subImage 相对主 image 的水平错位量 +const PORTRAIT_SHADOW_OFFSET_Y = 16; // subImage 相对主 image 的垂直错位量 +const PORTRAIT_SHADOW_SCALE = 1; // subImage 与主 image 同尺寸,仅做错位偏移 +export const PORTRAIT_TEXT_GAP_FROM_IMAGE = 8; +export const PORTRAIT_CONTENT_LINES = 3; +export const PORTRAIT_TITLE_LINE_HEIGHT = 34; +export const PORTRAIT_CONTENT_LINE_HEIGHT = 23; +const PORTRAIT_CONTENT_FONT_SIZE = 16; +export const PORTRAIT_TITLE_TO_CONTENT_GAP = 4; + +export const buildPortraitAxisMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec => { + const themeColor = spec.themeColor ?? '#e8543d'; + const lineStyle = spec.line?.style ?? {}; + const defaultFill = { + gradient: 'linear', + x0: 0, + y0: 0, + x1: 0, + y1: 1, + stops: [ + { offset: 0, color: withAlpha(themeColor, 0.2) }, + { offset: 1, color: withAlpha(themeColor, 1) } + ] + }; + // marker 时间节点文字:垂直方向逐字排列(每字符换行) + const markerVisible = spec.marker?.visible !== false; + + const markerMarks = markerVisible + ? (spec.data ?? []) + .map((block, index) => { + if (!block.marker) { + return null; + } + // image 在 block 左侧时(index 偶数),marker 贴轴左边缘 + 左对齐; + // image 在 block 右侧时(index 奇数),marker 贴轴右边缘 + 右对齐。 + const onLeft = index % 2 === 0; + const axisHalf = PORTRAIT_AXIS_WIDTH / 2; + const markerOffsetX = onLeft + ? -axisHalf + PORTRAIT_MARKER_AXIS_PADDING + : axisHalf - PORTRAIT_MARKER_AXIS_PADDING; + const markerTextAlign: 'left' | 'right' = onLeft ? 'left' : 'right'; + return { + type: 'text', + textType: 'rich', + name: `storyline-portrait-marker-${index}`, + interactive: false, + ...spec.marker, + style: { + x: (_d: unknown, ctx: LayoutContext) => { + const lb = getLayout(spec, ctx).blocks[index]; + return (lb?.center?.x ?? 0) + markerOffsetX; + }, + y: (_d: unknown, ctx: LayoutContext) => { + const lb = getLayout(spec, ctx).blocks[index]; + return lb?.center?.y ?? 0; + }, + text: (_d: unknown, ctx: LayoutContext) => { + const axis = getPortraitAxisRect(spec, ctx); + const markerFontSize = resolveMarkerFontSize( + spec, + ctx, + block.marker, + axis.height / Math.max(spec.data?.length ?? 1, 1), + PORTRAIT_MARKER_FONT_SIZE, + [16, 38] + ); + const markerLineHeight = resolveAdaptiveLineHeight( + markerFontSize, + spec.marker?.style as any, + PORTRAIT_MARKER_LINE_HEIGHT, + 0.9 + ); + return { + type: 'rich', + text: block.marker.split('').map((char, i, arr) => ({ + text: char + (i < arr.length - 1 ? '\n' : ''), + fontSize: markerFontSize, + lineHeight: markerLineHeight, + fill: '#fff', + align: markerTextAlign + })) + }; + }, + fontWeight: 'bold', + lineJoin: 'round', + shadowColor: 'rgba(0, 0, 0, 0.3)', + shadowBlur: 8, + shadowOffsetX: 0, + shadowOffsetY: 5, + textAlign: markerTextAlign, + textBaseline: 'middle', + ...(spec.marker?.style as any) + } + } as ICustomMarkSpec<'text'>; + }) + .filter(Boolean) + : []; + + return { + type: 'group' as any, + name: 'storyline-portrait-axis', + zIndex: LayoutZIndex.Mark, + children: [ + { + type: 'rect', + name: 'storyline-portrait-axis-rect', + interactive: false, + style: { + fill: (lineStyle as any).fill ?? defaultFill, + stroke: (lineStyle as any).stroke ?? false, + lineWidth: (lineStyle as any).lineWidth ?? 0, + cornerRadius: (lineStyle as any).cornerRadius ?? 0, + x: (_d: unknown, ctx: LayoutContext) => getPortraitAxisRect(spec, ctx).x, + y: (_d: unknown, ctx: LayoutContext) => getPortraitAxisRect(spec, ctx).y, + width: (_d: unknown, ctx: LayoutContext) => getPortraitAxisRect(spec, ctx).width, + height: (_d: unknown, ctx: LayoutContext) => getPortraitAxisRect(spec, ctx).height + } + } as ICustomMarkSpec<'rect'>, + ...(markerMarks as ICustomMarkSpec[]) + ] + }; +}; + +const getPortraitMetrics = ( + spec: IStorylineSpec, + blockWidth: number, + blockHeight: number, + index: number, + ctx: LayoutContext +) => { + const contentFontSize = Number((spec.content?.style as any)?.fontSize ?? PORTRAIT_CONTENT_FONT_SIZE); + const contentLineHeight = Number((spec.content?.style as any)?.lineHeight ?? PORTRAIT_CONTENT_LINE_HEIGHT); + const titleToContentGap = PORTRAIT_TITLE_TO_CONTENT_GAP; + + // 默认 image 高度 = blockHeight * 0.4(blockHeight = regionHeight / count,由 getLayout 计算); + // 默认 image 宽度 = blockWidth,让 image 横向自适应单个 block 槽位宽度 + const imageWidth = spec.image?.width ?? Math.max(blockWidth, 80); + const imageHeight = spec.image?.height ?? Math.round(blockHeight * PORTRAIT_IMAGE_HEIGHT_RATIO); + const titleFontSize = resolveTitleFontSize(spec, ctx, spec.data?.[index]?.title, imageWidth, 26, [8, 34]); + const titleLineHeight = resolveAdaptiveLineHeight( + titleFontSize, + spec.title?.style as any, + PORTRAIT_TITLE_LINE_HEIGHT + ); + const titleHeight = getBlockTitleHeight(titleLineHeight, spec.data?.[index]?.title, imageWidth, titleFontSize); + const minContentHeight = PORTRAIT_CONTENT_LINES * contentLineHeight; + // 默认 content 高度 = blockHeight * 0.4 + const contentHeight = Math.max(minContentHeight, Math.round(blockHeight * PORTRAIT_CONTENT_HEIGHT_RATIO)); + + const textHeight = titleHeight + titleToContentGap + contentHeight; + + const onLeft = index % 2 === 0; + + const axisHalf = PORTRAIT_AXIS_WIDTH / 2; + const imageX = onLeft + ? -axisHalf - PORTRAIT_IMAGE_GAP_FROM_AXIS - imageWidth + : axisHalf + PORTRAIT_IMAGE_GAP_FROM_AXIS; + const imageY = -imageHeight / 2; + + const textX = imageX; + const textY = imageY + imageHeight + PORTRAIT_TEXT_GAP_FROM_IMAGE; + const textWidth = imageWidth; + + const contentBox = { + x: textX, + y: textY + titleHeight + titleToContentGap, + width: textWidth, + height: contentHeight + }; + + const shadowOffsetX = PORTRAIT_SHADOW_OFFSET_X; + const shadowOffsetY = PORTRAIT_SHADOW_OFFSET_Y; + const shadowWidth = imageWidth * PORTRAIT_SHADOW_SCALE; + const shadowHeight = imageHeight * PORTRAIT_SHADOW_SCALE; + const baseShadowX = imageX - (shadowWidth - imageWidth) / 2; + const baseShadowY = imageY - (shadowHeight - imageHeight) / 2; + const shadowBox = { + x: baseShadowX + (onLeft ? -shadowOffsetX : shadowOffsetX), + y: baseShadowY + shadowOffsetY, + width: shadowWidth, + height: shadowHeight + }; + + return { + onLeft, + titleFontSize, + titleLineHeight, + titleHeight, + contentFontSize, + contentLineHeight, + blockWidth, + imageBox: { x: imageX, y: imageY, width: imageWidth, height: imageHeight }, + shadowBox, + textBox: { x: textX, y: textY, width: textWidth, height: textHeight }, + contentBox + }; +}; + +/** + * 获取 portrait 布局的中轴 rect 尺寸:宽度固定,高度覆盖首尾 block 的完整内容范围。 + */ +const getPortraitAxisRect = (spec: IStorylineSpec, ctx: LayoutContext) => { + const blocks = getLayout(spec, ctx).blocks; + if (!blocks.length) { + return { x: 0, y: 0, width: 0, height: 0 }; + } + let top = Number.POSITIVE_INFINITY; + let bottom = Number.NEGATIVE_INFINITY; + blocks.forEach((block, index) => { + const metrics = getPortraitMetrics(spec, block.width, block.height, index, ctx); + const localTop = Math.min(metrics.imageBox.y, metrics.shadowBox.y, metrics.textBox.y, metrics.contentBox.y); + const localBottom = Math.max( + metrics.imageBox.y + metrics.imageBox.height, + metrics.shadowBox.y + metrics.shadowBox.height, + metrics.textBox.y + metrics.textBox.height, + metrics.contentBox.y + metrics.contentBox.height + ); + top = Math.min(top, block.center.y + localTop); + bottom = Math.max(bottom, block.center.y + localBottom); + }); + const cx = blocks[0].center.x; + return { + x: cx - PORTRAIT_AXIS_WIDTH / 2, + y: top - PORTRAIT_AXIS_PADDING, + width: PORTRAIT_AXIS_WIDTH, + height: bottom - top + PORTRAIT_AXIS_PADDING * 2 + }; +}; + +export const buildPortraitBlockMark = ( + spec: IStorylineSpec, + block: IStorylineBlock, + index: number +): IExtensionGroupMarkSpec => { + const hasImage = !!block.image; + const hasSubImage = !!block.subImage; + const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; + + const getMetrics = (ctx: LayoutContext) => { + const lb = getLayout(spec, ctx).blocks[index]; + const w = lb?.width ?? resolveBlockWidth(spec, 0); + const h = lb?.height ?? spec.block?.height ?? DEFAULT_BLOCK_HEIGHT; + return getPortraitMetrics(spec, w, h, index, ctx); + }; + const blockStyle = spec.block?.style ?? {}; + + return { + type: 'group' as any, + id: `storyline-block-${block.id ?? index}`, + name: `storyline-block-${index}`, + zIndex: LayoutZIndex.Mark + 1, + style: { + x: (_d: unknown, ctx: LayoutContext) => { + const lb = getLayout(spec, ctx).blocks[index]; + return lb?.center?.x ?? 0; + }, + y: (_d: unknown, ctx: LayoutContext) => { + const lb = getLayout(spec, ctx).blocks[index]; + return lb?.center?.y ?? 0; + } + }, + children: [ + hasSubImage + ? ({ + type: 'image', + name: `storyline-block-shadow-image-${index}`, + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).shadowBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).shadowBox.y, + width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).shadowBox.width, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).shadowBox.height, + image: block.subImage, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'contain', + imagePosition: 'center' + } + } as ICustomMarkSpec<'image'>) + : null, + shouldShowImageBackground(spec) + ? ({ + type: 'rect', + name: `storyline-block-image-bg-${index}`, + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.y, + width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.width, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.height, + cornerRadius: 8, + ...getImageBackgroundStyle(spec), + ...blockStyle + } + } as ICustomMarkSpec<'rect'>) + : null, + hasImage + ? ({ + type: 'image', + name: `storyline-block-image-${index}`, + interactive: false, + ...omitImageLayoutSpec(spec.image), + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.y, + width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.width, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).imageBox.height, + image: block.image, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'contain', + imagePosition: 'center', + ...spec.image?.style + } + } as ICustomMarkSpec<'image'>) + : null, + block.title + ? ({ + type: 'text', + name: `storyline-block-title-${index}`, + interactive: false, + ...spec.title, + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).textBox.y, + text: block.title, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleHeight, + heightLimit: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleLineHeight * BLOCK_TITLE_MAX_LINES, + lineClamp: BLOCK_TITLE_MAX_LINES, + fontSize: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleFontSize, + lineHeight: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).titleLineHeight, + fontWeight: 'bold', + fill: '#1f2430', + stroke: '#fff', + lineWidth: 5, + lineJoin: 'round', + textAlign: 'left', + textBaseline: 'top', + whiteSpace: 'normal', + wordBreak: 'break-word', + ellipsis: '...', + ...spec.title?.style + } + } as ICustomMarkSpec<'text'>) + : null, + contentText.length + ? ({ + type: 'text', + name: `storyline-block-content-${index}`, + interactive: false, + ...spec.content, + style: { + x: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.x, + y: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.y, + width: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.width, + height: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.height, + maxLineWidth: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.width, + heightLimit: (_d: unknown, ctx: LayoutContext) => getMetrics(ctx).contentBox.height, + text: buildPlainContent(contentText), + fontSize: PORTRAIT_CONTENT_FONT_SIZE, + lineHeight: PORTRAIT_CONTENT_LINE_HEIGHT, + textAlign: 'left', + textBaseline: 'top', + whiteSpace: 'normal', + wordBreak: 'break-word', + ellipsis: '...', + fill: '#596173', + ...spec.content?.style + } + } as ICustomMarkSpec<'text'>) + : null + ].filter(Boolean) as ICustomMarkSpec[] + }; +}; diff --git a/packages/vchart-extension/src/charts/storyline/layouts/wing.ts b/packages/vchart-extension/src/charts/storyline/layouts/wing.ts new file mode 100644 index 0000000000..9165c6c235 --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/layouts/wing.ts @@ -0,0 +1,698 @@ +import type { IExtensionGroupMarkSpec } from '@visactor/vchart'; +import { LayoutZIndex, measureText } from '@visactor/vchart'; +import type { IStorylineBlock, IStorylineSpec, StorylineWingDirection } from '../interface'; +import { + type ICustomMarkSpec, + type LayoutContext, + type StorylinePoint, + BLOCK_TITLE_MAX_LINES, + buildPlainContent, + getImageBackgroundStyle, + getChartGeometry, + getRegionGeometry, + getThemeColor, + getTitleImageSize, + normalizeLayout, + omitImageLayoutSpec, + resolveAdaptiveLineHeight, + shouldShowImageBackground +} from './common'; + +// wing 布局:参考残奥时间线信息图 +// - 主脉络为椭圆弧的「翅膀」造型,可通过 direction 配置左右朝向 +// - direction: 'left' → 圆心锚在画布左侧,弧凸向右展开(默认) +// - direction: 'right' → 圆心锚在画布右侧,弧凸向左展开 +// - 圆形 image 嵌在弧线上(中心位于弧线) +// - title(年份感大字 + 主题色) + content 在 image 一侧水平展开 +// - 左右交替(弧线左侧 / 右侧)让节点错落 +const WING_BLOCK_IMAGE_SIZE = 160; +const WING_TEXT_GAP_FROM_IMAGE = 14; +const WING_TITLE_LINE_HEIGHT = 30; +const WING_TITLE_FONT_SIZE = 22; +const WING_TITLE_MIN_FONT_SIZE = 14; +const WING_TITLE_MAX_FONT_SIZE = 30; +const WING_CONTENT_LINE_HEIGHT = 17; +const WING_CONTENT_FONT_SIZE = 12; +// title + content 区域宽度 +const WING_TEXT_BOX_WIDTH = 240; +const WING_TEXT_BOX_MIN_WIDTH = 240; +const WING_TEXT_BOX_MAX_WIDTH = 360; +const WING_TEXT_BOX_WIDTH_RATIO = 0.24; +// title + content 区域总高度 +const WING_TEXT_BOX_HEIGHT = 110; +const WING_TITLE_TO_CONTENT_GAP = 4; +const WING_TITLE_IMAGE_WIDTH_RATIO = 0.6; +const WING_TITLE_IMAGE_MAX_WIDTH = 820; +const WING_DEFAULT_PATH_END_WIDTH = 350; +const WING_IMAGE_MIN_SCALE = 0.52; +const WING_IMAGE_MAX_SCALE = 1.2; +const WING_BOTTOM_TEXT_IMAGE_GAP_RATIO = 1.35; +const WING_PATH_EDGE_GAP = 8; + +const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)); + +const getWingDirection = (spec: IStorylineSpec): StorylineWingDirection => { + return normalizeLayout(spec.layout).direction === 'right' ? 'right' : 'left'; +}; + +const getWingPathWidthRange = (spec: IStorylineSpec) => { + const lineStyle = (spec.line?.style ?? {}) as Record; + const startWidth = Math.max(Number(lineStyle.startWidth ?? 50), 0.5); + const endWidth = Math.max( + Number(lineStyle.endWidth ?? lineStyle.lineWidth ?? WING_DEFAULT_PATH_END_WIDTH), + startWidth + ); + return { startWidth, endWidth }; +}; + +const getWingBlockPathRatio = (spec: IStorylineSpec, index: number) => { + const count = spec.data?.length ?? 0; + if (count <= 1) { + return 0.5; + } + return index / (count - 1); +}; + +const getWingBlockImageScale = (spec: IStorylineSpec, index: number) => { + const { startWidth, endWidth } = getWingPathWidthRange(spec); + const startScale = clamp(startWidth / WING_DEFAULT_PATH_END_WIDTH, WING_IMAGE_MIN_SCALE, WING_IMAGE_MAX_SCALE); + const endScale = clamp(endWidth / WING_DEFAULT_PATH_END_WIDTH, WING_IMAGE_MIN_SCALE, WING_IMAGE_MAX_SCALE); + return startScale + (endScale - startScale) * getWingBlockPathRatio(spec, index); +}; + +const getConfiguredTextBoxWidth = (spec: IStorylineSpec) => { + const style = spec.title?.style as Record | undefined; + const width = Number(style?.width ?? style?.maxLineWidth ?? WING_TEXT_BOX_WIDTH); + return Number.isFinite(width) && width > 0 ? width : WING_TEXT_BOX_WIDTH; +}; + +const getWingTextBoxWidth = (spec: IStorylineSpec, ctx: LayoutContext) => { + const { width } = getRegionGeometry(ctx, spec); + const configuredWidth = getConfiguredTextBoxWidth(spec); + const adaptiveWidth = Math.max(configuredWidth, Math.round(width * WING_TEXT_BOX_WIDTH_RATIO)); + return clamp(adaptiveWidth, WING_TEXT_BOX_MIN_WIDTH, WING_TEXT_BOX_MAX_WIDTH); +}; + +const estimateTextWidth = (text: string, fontSize: number) => + Array.from(text).reduce((sum, char) => { + if (char.trim().length === 0) { + return sum + fontSize * 0.32; + } + return sum + fontSize * (char.charCodeAt(0) > 255 ? 1.05 : 0.62); + }, 0); + +const measureWingTitleWidth = (title: string | undefined, fontSize: number, style: Record) => { + if (!title) { + return 0; + } + try { + const size = measureText(title, { + fontSize, + fontFamily: style.fontFamily as string | undefined, + fontWeight: (style.fontWeight as any) ?? 'bold' + }); + return Number.isFinite(size.width) && size.width > 0 ? size.width : estimateTextWidth(title, fontSize); + } catch (_err) { + return estimateTextWidth(title, fontSize); + } +}; + +const getMeasuredTitleLineCount = ( + title: string | undefined, + fontSize: number, + textWidth: number, + style: Record +) => { + if (!title) { + return 0; + } + const measuredWidth = measureWingTitleWidth(title, fontSize, style); + return Math.max(1, Math.min(BLOCK_TITLE_MAX_LINES, Math.ceil(measuredWidth / Math.max(textWidth * 0.96, 1)))); +}; + +const resolveWingTitleFontSize = (spec: IStorylineSpec, index: number, textWidth: number) => { + const style = (spec.title?.style ?? {}) as Record; + const configuredFontSize = style.fontSize; + if (configuredFontSize != null) { + return Number(configuredFontSize); + } + const title = spec.data?.[index]?.title; + const measuredWidth = measureWingTitleWidth(title, WING_TITLE_FONT_SIZE, style); + if (!measuredWidth) { + return WING_TITLE_FONT_SIZE; + } + const widthLimit = textWidth * BLOCK_TITLE_MAX_LINES * 0.96; + const measuredPerFont = measuredWidth / WING_TITLE_FONT_SIZE; + const fittedFontSize = Math.floor(widthLimit / Math.max(measuredPerFont, 1)); + return clamp(fittedFontSize, WING_TITLE_MIN_FONT_SIZE, WING_TITLE_MAX_FONT_SIZE); +}; + +/** + * 计算 wing 弧线的几何参数: + * - direction='left':圆心位于 inner 左侧,采样区间 -70°→70°(cos>0),弧线点位于圆心右侧; + * - direction='right':圆心位于 inner 右侧,采样区间 110°→250°(cos<0),弧线点位于圆心左侧。 + */ +const getWingArcGeometry = (spec: IStorylineSpec, ctx: LayoutContext) => { + const { width, height, startX, startY } = getRegionGeometry(ctx); + // width/height 已经是 VChart 减去 spec.padding 后的 region 大小 + // 不要再重复减去 padding,直接用 region 几何信息定位弧线 + const innerWidth = Math.max(width, 1); + const innerHeight = Math.max(height, 1); + const layoutOpt = normalizeLayout(spec.layout); + const direction = getWingDirection(spec); + const defaultStart = direction === 'right' ? 110 : -70; + const defaultEnd = direction === 'right' ? 250 : 70; + const startAngle = layoutOpt.startAngle ?? defaultStart; + const endAngle = layoutOpt.endAngle ?? defaultEnd; + const ratio = layoutOpt.radiusRatio ?? 0.92; + const { startWidth, endWidth } = getWingPathWidthRange(spec); + const topPathWidth = direction === 'right' ? endWidth : startWidth; + const pathTopInset = Math.min(Math.max(innerHeight / 2 - 1, 0), Math.ceil(topPathWidth / 2 + WING_PATH_EDGE_GAP)); + const arcHeight = Math.max(innerHeight - pathTopInset, 1); + const ry = (arcHeight / 2) * ratio; + const rx = innerWidth * 0.6 * ratio; + const topSin = Math.min(Math.sin((startAngle / 180) * Math.PI), Math.sin((endAngle / 180) * Math.PI), 0); + // direction='right':圆心锚在 region 右侧,弧线点在左侧;direction='left':圆心锚在左侧 + const cx = direction === 'right' ? startX + innerWidth - rx * 0.1 : startX + rx * 0.1; + const cy = startY + pathTopInset - topSin * ry; + return { cx, cy, rx, ry, startAngle, endAngle }; +}; + +/** + * 沿弧采样 block 中心 —— image 的圆心直接在弧线上,与时间线视觉对齐。 + */ +const getWingBlockCenter = (spec: IStorylineSpec, ctx: LayoutContext, index: number): StorylinePoint => { + const arc = getWingArcGeometry(spec, ctx); + const count = spec.data?.length ?? 0; + if (count <= 0) { + return { x: arc.cx, y: arc.cy }; + } + const t = count === 1 ? 0.5 : index / (count - 1); + const angle = ((arc.startAngle + (arc.endAngle - arc.startAngle) * t) / 180) * Math.PI; + return { + x: arc.cx + Math.cos(angle) * arc.rx, + y: arc.cy + Math.sin(angle) * arc.ry + }; +}; + +/** + * 节点文字侧向: + * - 左翅膀(弧凸向右):偶数节点的文字排在弧线左侧; + * - 右翅膀(弧凸向左):偶数节点的文字排在弧线右侧(即镜像)。 + */ +const isTextOnLeft = (spec: IStorylineSpec, index: number) => { + const direction = getWingDirection(spec); + return direction === 'right' ? index % 2 === 1 : index % 2 === 0; +}; + +const isWingBottomBlock = (spec: IStorylineSpec, ctx: LayoutContext, index: number) => { + const count = spec.data?.length ?? 0; + if (count <= 0) { + return false; + } + let bottomIndex = 0; + let bottomY = Number.NEGATIVE_INFINITY; + for (let i = 0; i < count; i++) { + const center = getWingBlockCenter(spec, ctx, i); + if (center.y > bottomY) { + bottomY = center.y; + bottomIndex = i; + } + } + return index === bottomIndex; +}; + +/** + * 主脉络曲线 mark:贯穿所有 block 的椭圆弧。 + * 用变宽的 filled path 模拟"丝带"——起点窄、终点宽,与信息图视觉一致。 + * 默认展示;用户可通过 spec.line.visible = false 关闭。 + */ +export const buildWingArcMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + if (spec.line?.visible === false) { + return null; + } + const themeColor = getThemeColor(spec); + const lineStyle = (spec.line?.style ?? {}) as Record; + const { startWidth, endWidth } = getWingPathWidthRange(spec); + return { + type: 'group' as any, + name: 'storyline-wing-arc', + zIndex: LayoutZIndex.Mark, + children: [ + { + type: 'path', + name: 'storyline-wing-arc-path', + interactive: false, + style: { + stroke: false, + lineWidth: 0, + fill: (lineStyle.fill as string) ?? (lineStyle.stroke as string) ?? themeColor, + opacity: 0.95, + path: (_d: unknown, ctx: LayoutContext) => { + const arc = getWingArcGeometry(spec, ctx); + const span = arc.endAngle - arc.startAngle; + const samples = 96; + const pts: { x: number; y: number; nx: number; ny: number; w: number }[] = []; + for (let i = 0; i <= samples; i++) { + const t = i / samples; + const angle = ((arc.startAngle + span * t) / 180) * Math.PI; + const cx = arc.cx + Math.cos(angle) * arc.rx; + const cy = arc.cy + Math.sin(angle) * arc.ry; + const nxRaw = Math.cos(angle) / arc.rx; + const nyRaw = Math.sin(angle) / arc.ry; + const nLen = Math.hypot(nxRaw, nyRaw) || 1; + pts.push({ + x: cx, + y: cy, + nx: nxRaw / nLen, + ny: nyRaw / nLen, + w: startWidth + (endWidth - startWidth) * t + }); + } + const segments: string[] = []; + for (let i = 0; i < pts.length; i++) { + const p = pts[i]; + const x = p.x + p.nx * (p.w / 2); + const y = p.y + p.ny * (p.w / 2); + segments.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`); + } + for (let i = pts.length - 1; i >= 0; i--) { + const p = pts[i]; + const x = p.x - p.nx * (p.w / 2); + const y = p.y - p.ny * (p.w / 2); + segments.push(`L ${x.toFixed(2)} ${y.toFixed(2)}`); + } + segments.push('Z'); + return segments.join(' '); + } + } + } as ICustomMarkSpec<'path'> + ] + }; +}; + +export const buildWingTitleImageMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + if (!spec.titleImage?.image || spec.titleImage.visible === false) { + return null; + } + return { + type: 'group' as any, + name: 'storyline-wing-title-image', + zIndex: LayoutZIndex.Mark + 8, + children: [ + { + type: 'rect', + name: 'storyline-wing-title-image-node', + interactive: false, + ...spec.titleImage, + _debug_bounds: true, + style: { + _debug_bounds: true, + x: (_d: unknown, ctx: LayoutContext) => { + const { width, height, startX } = getChartGeometry(ctx, spec); + const size = getTitleImageSize(spec, width, height, { + widthRatio: WING_TITLE_IMAGE_WIDTH_RATIO, + maxWidth: WING_TITLE_IMAGE_MAX_WIDTH + }); + return getWingDirection(spec) === 'right' ? startX : startX + width - size.width; + }, + fill: 'red', + y: (_d: unknown, ctx: LayoutContext) => { + return getChartGeometry(ctx, spec).startY + 12; + }, + width: (_d: unknown, ctx: LayoutContext) => { + const { width, height } = getChartGeometry(ctx, spec); + return getTitleImageSize(spec, width, height, { + widthRatio: WING_TITLE_IMAGE_WIDTH_RATIO, + maxWidth: WING_TITLE_IMAGE_MAX_WIDTH + }).width; + }, + height: (_d: unknown, ctx: LayoutContext) => { + const { width, height } = getChartGeometry(ctx, spec); + return getTitleImageSize(spec, width, height, { + widthRatio: WING_TITLE_IMAGE_WIDTH_RATIO, + maxWidth: WING_TITLE_IMAGE_MAX_WIDTH + }).height; + }, + image: spec.titleImage.image, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'contain', + imagePosition: 'center', + ...spec.titleImage.style + } + } as ICustomMarkSpec<'rect'>, + { + type: 'image', + name: 'storyline-wing-title-image-node', + interactive: false, + ...spec.titleImage, + _debug_bounds: true, + style: { + _debug_bounds: true, + x: (_d: unknown, ctx: LayoutContext) => { + const { width, height, startX } = getChartGeometry(ctx, spec); + const size = getTitleImageSize(spec, width, height, { + widthRatio: WING_TITLE_IMAGE_WIDTH_RATIO, + maxWidth: WING_TITLE_IMAGE_MAX_WIDTH + }); + return getWingDirection(spec) === 'right' ? startX : startX + width - size.width; + }, + y: (_d: unknown, ctx: LayoutContext) => { + return getChartGeometry(ctx, spec).startY + 12; + }, + width: (_d: unknown, ctx: LayoutContext) => { + const { width, height } = getChartGeometry(ctx, spec); + return getTitleImageSize(spec, width, height, { + widthRatio: WING_TITLE_IMAGE_WIDTH_RATIO, + maxWidth: WING_TITLE_IMAGE_MAX_WIDTH + }).width; + }, + height: (_d: unknown, ctx: LayoutContext) => { + const { width, height } = getChartGeometry(ctx, spec); + return getTitleImageSize(spec, width, height, { + widthRatio: WING_TITLE_IMAGE_WIDTH_RATIO, + maxWidth: WING_TITLE_IMAGE_MAX_WIDTH + }).height; + }, + image: spec.titleImage.image, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'contain', + imagePosition: 'center', + ...spec.titleImage.style + } + } as ICustomMarkSpec<'image'> + ] + }; +}; + +// text box 与 image 的水平间距(image 左边缘到 text box 右边缘的距离) +const WING_TEXT_IMAGE_GAP = 120; + +const getWingBlockMetrics = (spec: IStorylineSpec, ctx: LayoutContext, index: number) => { + const textWidth = getWingTextBoxWidth(spec, ctx); + const titleStyle = (spec.title?.style ?? {}) as Record; + const titleFontSize = resolveWingTitleFontSize(spec, index, textWidth); + const titleLineHeight = resolveAdaptiveLineHeight(titleFontSize, titleStyle, WING_TITLE_LINE_HEIGHT, 1.3); + const titleHeight = + titleLineHeight * getMeasuredTitleLineCount(spec.data?.[index]?.title, titleFontSize, textWidth, titleStyle); + const contentFontSize = Number((spec.content?.style as Record)?.fontSize ?? WING_CONTENT_FONT_SIZE); + const contentLineHeight = Number( + (spec.content?.style as Record)?.lineHeight ?? WING_CONTENT_LINE_HEIGHT + ); + const titleToContentGap = WING_TITLE_TO_CONTENT_GAP; + const textHeight = WING_TEXT_BOX_HEIGHT; + const contentHeight = 100000; + + const imageScale = getWingBlockImageScale(spec, index); + const imageWidth = Math.round(Number(spec.image?.width ?? WING_BLOCK_IMAGE_SIZE) * imageScale); + const imageHeight = Math.round(Number(spec.image?.height ?? WING_BLOCK_IMAGE_SIZE) * imageScale); + const imageBox = { + x: -imageWidth / 2, + y: -imageHeight / 2, + width: imageWidth, + height: imageHeight + }; + + const direction = getWingDirection(spec); + const count = spec.data?.length ?? 0; + + // 特殊块的垂直布局:文字在 image 下方,水平居中 + // - direction='right' → 最后一个 block + // - direction='left' → 第一个 block + const isSpecialBelow = + (direction === 'right' && count > 0 && index === count - 1) || (direction === 'left' && index === 0); + const isBottomAbove = isWingBottomBlock(spec, ctx, index); + const isVerticalLayout = isBottomAbove || isSpecialBelow; + + let textBox; + let contentBox; + let connectorBox; + let onLeft; + let verticalAlign; // 'below' | 'above' | null + + if (isBottomAbove) { + // 最下面的 block:text 在 image 上方,水平居中,并给更大的 y 方向间距。 + const bottomTextImageGap = Math.round(WING_TEXT_IMAGE_GAP * WING_BOTTOM_TEXT_IMAGE_GAP_RATIO); + const textX = -textWidth / 2; + const textY = -imageHeight / 2 - bottomTextImageGap - textHeight; + textBox = { x: textX, y: textY, width: textWidth, height: textHeight }; + contentBox = { + x: textX, + y: textY + titleHeight + titleToContentGap, + width: textWidth, + height: contentHeight + }; + // 垂直引导线:从 text 底部到 image 顶部 + connectorBox = { + x: -1, + y: textY + textHeight, + width: 2, + height: bottomTextImageGap + }; + onLeft = false; + verticalAlign = 'above'; + } else if (isVerticalLayout) { + // 垂直布局:text 在 image 下方,水平居中 + const textX = -textWidth / 2; + const textY = imageHeight / 2 + WING_TEXT_IMAGE_GAP; + textBox = { x: textX, y: textY, width: textWidth, height: textHeight }; + contentBox = { + x: textX, + y: textY + titleHeight + titleToContentGap, + width: textWidth, + height: contentHeight + }; + // 垂直引导线:从 image 底部到 text 顶部 + connectorBox = { + x: -1, + y: imageHeight / 2, + width: 2, + height: WING_TEXT_IMAGE_GAP + }; + onLeft = false; + verticalAlign = 'below'; + } else { + // 水平布局(默认):text 在 image 一侧 + const textOnLeft = direction === 'right'; + const textX = textOnLeft ? -imageWidth / 2 - WING_TEXT_IMAGE_GAP - textWidth : imageWidth / 2 + WING_TEXT_IMAGE_GAP; + const textY = -textHeight / 2; + textBox = { x: textX, y: textY, width: textWidth, height: textHeight }; + contentBox = { + x: textX, + y: textY + titleHeight + titleToContentGap, + width: textWidth, + height: contentHeight + }; + // 水平引导线 + const imageEdgeX = textOnLeft ? -imageWidth / 2 : imageWidth / 2; + const textEdgeX = textOnLeft ? textX + textWidth : textX; + connectorBox = { + x: Math.min(imageEdgeX, textEdgeX), + y: 0, + width: Math.abs(textEdgeX - imageEdgeX), + height: 2 + }; + onLeft = textOnLeft; + verticalAlign = null; + } + + return { + onLeft, + verticalAlign, + titleFontSize, + titleLineHeight, + titleHeight, + contentFontSize, + contentLineHeight, + imageBox, + textBox, + contentBox, + connectorBox + }; +}; + +export const buildWingBlockMark = ( + spec: IStorylineSpec, + block: IStorylineBlock, + index: number +): IExtensionGroupMarkSpec => { + const hasImage = !!block.image; + const contentText = Array.isArray(block.content) ? block.content : block.content ? [block.content] : []; + const themeColor = getThemeColor(spec); + // image 背后的装饰图元(halo) + const showBackground = shouldShowImageBackground(spec); + + return { + type: 'group' as any, + id: `storyline-block-${block.id ?? index}`, + name: `storyline-block-${index}`, + zIndex: LayoutZIndex.Mark + 1, + style: { + x: (_d: unknown, ctx: LayoutContext) => getWingBlockCenter(spec, ctx, index).x, + y: (_d: unknown, ctx: LayoutContext) => getWingBlockCenter(spec, ctx, index).y + }, + children: [ + // 引导线:连接 image 和 text box + { + type: 'rect', + name: `storyline-block-connector-${index}`, + interactive: false, + zIndex: LayoutZIndex.Mark + 2, + style: { + x: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).connectorBox.x, + y: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).connectorBox.y, + width: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).connectorBox.width, + height: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).connectorBox.height, + fill: themeColor, + opacity: 0.6 + } + } as ICustomMarkSpec<'rect'>, + showBackground + ? ({ + type: 'symbol', + name: `storyline-block-image-halo-${index}`, + interactive: false, + style: { + x: 0, + y: 0, + size: (_d: unknown, ctx: LayoutContext) => + Math.max( + getWingBlockMetrics(spec, ctx, index).imageBox.width, + getWingBlockMetrics(spec, ctx, index).imageBox.height + ) + 12, + symbolType: 'circle', + ...getImageBackgroundStyle(spec), + lineWidth: 1.5 + } + } as ICustomMarkSpec<'symbol'>) + : null, + hasImage + ? ({ + type: 'image', + name: `storyline-block-image-${index}`, + interactive: false, + ...omitImageLayoutSpec(spec.image), + style: { + x: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).imageBox.x, + y: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).imageBox.y, + width: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).imageBox.width, + height: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).imageBox.height, + cornerRadius: (_d: unknown, ctx: LayoutContext) => + Math.min( + getWingBlockMetrics(spec, ctx, index).imageBox.width, + getWingBlockMetrics(spec, ctx, index).imageBox.height + ) / 2, + image: block.image, + repeatX: 'no-repeat', + repeatY: 'no-repeat', + imageMode: 'contain', + imagePosition: 'center', + ...spec.image?.style + } + } as ICustomMarkSpec<'image'>) + : ({ + type: 'rect', + name: `storyline-block-image-bg-${index}`, + interactive: false, + style: { + x: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).imageBox.x, + y: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).imageBox.y, + width: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).imageBox.width, + height: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).imageBox.height, + cornerRadius: (_d: unknown, ctx: LayoutContext) => + Math.min( + getWingBlockMetrics(spec, ctx, index).imageBox.width, + getWingBlockMetrics(spec, ctx, index).imageBox.height + ) / 2, + ...getImageBackgroundStyle(spec) + } + } as ICustomMarkSpec<'rect'>), + block.title + ? ({ + type: 'text', + name: `storyline-block-title-${index}`, + interactive: false, + zIndex: LayoutZIndex.Mark + 10, + ...spec.title, + style: { + x: (_d: unknown, ctx: LayoutContext) => { + const m = getWingBlockMetrics(spec, ctx, index); + if (m.verticalAlign) { + return m.textBox.x + m.textBox.width / 2; + } + return m.onLeft ? m.textBox.x + m.textBox.width : m.textBox.x; + }, + y: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).textBox.y, + width: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).textBox.width, + maxLineWidth: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).textBox.width, + text: block.title, + height: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).titleHeight, + heightLimit: (_d: unknown, ctx: LayoutContext) => + getWingBlockMetrics(spec, ctx, index).titleLineHeight * BLOCK_TITLE_MAX_LINES, + lineClamp: BLOCK_TITLE_MAX_LINES, + fontSize: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).titleFontSize, + lineHeight: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).titleLineHeight, + fontWeight: 'bold', + fill: themeColor, + stroke: '#fff', + lineWidth: 5, + lineJoin: 'round', + textAlign: (_d: unknown, ctx: LayoutContext) => { + const m = getWingBlockMetrics(spec, ctx, index); + if (m.verticalAlign) { + return 'center'; + } + return m.onLeft ? 'right' : 'left'; + }, + textBaseline: 'top', + whiteSpace: 'normal', + wordBreak: 'break-word', + ellipsis: '...', + ...spec.title?.style + } + } as ICustomMarkSpec<'text'>) + : null, + contentText.length + ? ({ + type: 'text', + name: `storyline-block-content-${index}`, + interactive: false, + zIndex: LayoutZIndex.Mark + 10, + ...spec.content, + style: { + x: (_d: unknown, ctx: LayoutContext) => { + const m = getWingBlockMetrics(spec, ctx, index); + if (m.verticalAlign) { + return m.contentBox.x + m.contentBox.width / 2; + } + return m.onLeft ? m.contentBox.x + m.contentBox.width : m.contentBox.x; + }, + y: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).contentBox.y, + width: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).contentBox.width, + height: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).contentBox.height, + maxLineWidth: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).contentBox.width, + heightLimit: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).contentBox.height, + text: buildPlainContent(contentText), + fontSize: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).contentFontSize, + lineHeight: (_d: unknown, ctx: LayoutContext) => getWingBlockMetrics(spec, ctx, index).contentLineHeight, + textAlign: (_d: unknown, ctx: LayoutContext) => { + const m = getWingBlockMetrics(spec, ctx, index); + if (m.verticalAlign) { + return 'center'; + } + return m.onLeft ? 'right' : 'left'; + }, + textBaseline: 'top', + whiteSpace: 'normal', + wordBreak: 'break-word', + ellipsis: '...', + fill: '#1f2430', + ...spec.content?.style + } + } as ICustomMarkSpec<'text'>) + : null + ].filter(Boolean) as ICustomMarkSpec[] + }; +}; diff --git a/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts b/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts new file mode 100644 index 0000000000..d397a31c46 --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/storyline-transformer.ts @@ -0,0 +1,240 @@ +import { CommonChartSpecTransformer, type IExtensionGroupMarkSpec } from '@visactor/vchart'; +import type { IStorylineBlock, IStorylineSpec } from './interface'; +import { + isArc, + isClock, + isLandscape, + isPortrait, + isWing, + normalizeLayout, + buildTopTitleImageMark, + getTitleImageReservedHeight +} from './layouts/common'; +import { buildClockArcMark, buildClockBlockMark } from './layouts/clock'; +import { buildDefaultBlockMark, buildDefaultLineMark } from './layouts/default'; +import { buildLandscapeBlockMark, buildLandscapeConnectingCurve } from './layouts/landscape'; +import { + buildPortraitAxisMark, + buildPortraitBlockMark, + PORTRAIT_CONTENT_HEIGHT_RATIO, + PORTRAIT_IMAGE_HEIGHT_RATIO +} from './layouts/portrait'; +import { buildArcBlockMark, buildArcMark, buildArcTitleImageMark } from './layouts/arc'; +import { buildWingArcMark, buildWingBlockMark, buildWingTitleImageMark } from './layouts/wing'; + +export class StorylineChartSpecTransformer extends CommonChartSpecTransformer { + transformSpec(spec: any): void { + applyDefaultPadding(spec); + const storylineSpec = { + ...spec, + data: [...(spec.data ?? [])] + } as IStorylineSpec; + + spec.type = 'common' as any; + spec.data = []; + spec.series = []; + spec.axes = []; + spec.customMark = buildStorylineMarks(storylineSpec); + delete spec.layout; + delete spec.title; + super.transformSpec(spec as any); + } +} + +/** + * 图表默认 padding: + * - arc up(dome 穹顶):titleImage 贴底 + textBox 在弧线上方,所以默认底部留 100px、顶部留 280px 给 textBox + 弧线呼吸空间; + * - arc down(bowl 碗形):titleImage 贴顶 + textBox 在弧线下方,所以默认顶部留 100px、底部留 280px 给 textBox + 弧线呼吸空间; + * - portrait:textBox 在 image 下方,最后一个 block 的 content 容易超出 region。底部 padding 默认 = 单个 block 的 content 高度 + * (即 regionHeight / count * 0.6),保证最后一个 block 有完整的 content 展示空间; + * 用户在 spec.padding 中显式指定的值会被保留;顶部 titleImage 会额外保证最小 top padding,避免覆盖 region 内容。 + */ +const applyDefaultPadding = (spec: any) => { + const LARGE = 100; + const SMALL = 20; + const WING_BASE_PADDING = 40; + const WING_DIRECTION_PADDING = 100; + const WING_TITLE_IMAGE_BOTTOM = 4; + // 给 textBox(240px)+ 一定呼吸空间,避免内容超出画布 + const TEXT_RESERVE = 280; + const arc = isArc(spec as IStorylineSpec); + const arcDown = arc && normalizeLayout((spec as IStorylineSpec).layout).direction === 'down'; + const arcUp = arc && !arcDown; + const portrait = isPortrait(spec as IStorylineSpec); + const wing = isWing(spec as IStorylineSpec); + const wingDirection = wing ? normalizeLayout((spec as IStorylineSpec).layout).direction : undefined; + const wingLeft = wing && wingDirection !== 'right'; + const wingRight = wing && wingDirection === 'right'; + const wingTopPadding = wingRight ? WING_DIRECTION_PADDING : WING_BASE_PADDING; + const wingBottomPadding = wingLeft ? WING_DIRECTION_PADDING : WING_BASE_PADDING; + const clock = isClock(spec as IStorylineSpec); + const topTitleImageReserve = (() => { + if (arc || !(spec as IStorylineSpec).titleImage?.image || (spec as IStorylineSpec).titleImage?.visible === false) { + return 0; + } + return getTitleImageReservedHeight( + spec as IStorylineSpec, + Number((spec as IStorylineSpec).width ?? 1000), + Number((spec as IStorylineSpec).height ?? 600), + wing ? { bottom: WING_TITLE_IMAGE_BOTTOM } : undefined + ); + })(); + // clock 辐射式布局:底部和顶部 blocks 的文字会向外延伸,需要在四周围留空间 + // portrait 底部 padding:精准预留最后一个 block 的 content 展示空间。 + // portrait 几何(layouts/portrait.ts): + // - 每个 block center.y 等距放置 + // - image 中心 = block center;imageHeight ≈ slotHeight * PORTRAIT_IMAGE_HEIGHT_RATIO + // - content 紧贴 image 下方:textY = image 底 + textGap;textHeight = titleLine + titleGap + contentHeight + // - contentHeight ≈ slotHeight * PORTRAIT_CONTENT_HEIGHT_RATIO + // 最后一个 block center 到 canvas 底部需要至少 imageH/2 + textGap + titleLine + titleGap + contentH。 + // transformSpec 阶段无法获得真实 region,使用 spec.height 估算 slotHeight;缺省回退到 LARGE。 + const portraitBottomReserve = (() => { + if (!portrait) { + return 0; + } + const count = (spec as IStorylineSpec).data?.length ?? 0; + const canvasHeight = (spec as IStorylineSpec).height as number | undefined; + if (!count || !canvasHeight) { + return LARGE; + } + // 在 transformSpec 阶段还没经过 region 减去 padding 等步骤, + // 这里直接用 canvasHeight / (count + 1) 作为 slotHeight 的近似上界, + // 后续 layout 逻辑会基于真实 region 重新计算 imageH / contentH。 + const slotHeight = canvasHeight / (count + 1); + const imageHeight = (spec as IStorylineSpec).image?.height ?? slotHeight * PORTRAIT_IMAGE_HEIGHT_RATIO; + const contentHeight = slotHeight * PORTRAIT_CONTENT_HEIGHT_RATIO; + const titleFontSize = Number((spec.title?.style as any)?.fontSize ?? 26); + const titleLineHeight = Number((spec.title?.style as any)?.lineHeight ?? Math.round(titleFontSize * 1.35)); + const textGap = 8; // PORTRAIT_TEXT_GAP_FROM_IMAGE + const titleToContentGap = 4; // PORTRAIT_TITLE_TO_CONTENT_GAP + const breath = 16; // 额外呼吸空间 + return Math.max( + LARGE, + Math.round(imageHeight / 2 + textGap + titleLineHeight + titleToContentGap + contentHeight + breath) + ); + })(); + // arc up(dome): 顶部留给 textBox,底部紧贴(不要额外 padding) + // arc down(bowl): 底部留给 textBox,顶部紧贴(不要额外 padding) + // portrait: 底部留给最后一个 block 的 content + // wing:direction='left' 时底部多留一点;direction='right' 时顶部多留一点。 + // 其它:保持原默认 [SMALL, SMALL, LARGE, SMALL] + const defaultTop = Math.max( + topTitleImageReserve, + clock ? 40 : wing ? wingTopPadding : arcDown ? 0 : arcUp ? TEXT_RESERVE : SMALL + ); + const defaultBottom = clock + ? 60 + : portrait + ? portraitBottomReserve + : wing + ? wingBottomPadding + : arcUp + ? 100 + : arcDown + ? TEXT_RESERVE + : LARGE; + // arc:左右 padding = content 宽度(canvasWidth / (count + 1)),保证内容沿弧线均匀分布 + const arcHorizontalPadding = (() => { + if (!arc) { + return SMALL; + } + const count = Math.max((spec as IStorylineSpec).data?.length ?? 0, 1); + const canvasWidth = (spec as IStorylineSpec).width as number | undefined; + if (!canvasWidth) { + return SMALL; + } + return Math.round(canvasWidth / (count + 1)); + })(); + const defaultLeft = clock ? 40 : arcHorizontalPadding; + const defaultRight = clock ? 40 : arcHorizontalPadding; + + const p = spec.padding; + if (p == null) { + spec.padding = [defaultTop, defaultRight, defaultBottom, defaultLeft]; + return; + } + if (typeof p === 'number') { + spec.padding = [Math.max(p, topTitleImageReserve), p, p, p]; + return; + } + if (Array.isArray(p)) { + const [t, r = defaultRight, b, l = defaultLeft] = p; + spec.padding = [Math.max(t ?? defaultTop, topTitleImageReserve), r, b ?? defaultBottom, l]; + return; + } + if (typeof p === 'object') { + spec.padding = { + top: Math.max(p.top ?? defaultTop, topTitleImageReserve), + right: p.right ?? defaultRight, + bottom: p.bottom ?? defaultBottom, + left: p.left ?? defaultLeft + }; + } +}; + +const buildStorylineMarks = (spec: IStorylineSpec) => { + const lineMark = buildLineMark(spec); + const blockMarks = (spec.data ?? []).map((block, index) => buildBlockMark(spec, block, index)); + const titleImageMark = buildTopTitleImageMark(spec); + // landscape:连接曲线绘制在所有 block 之上,避免被 image 遮挡 + if (isLandscape(spec)) { + return [titleImageMark, ...blockMarks, lineMark].filter(Boolean) as IExtensionGroupMarkSpec[]; + } + // portrait:lineMark 是中轴 rect,作为底层背景先绘制 + if (isPortrait(spec)) { + return [lineMark, titleImageMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; + } + // arc:先绘制 titleImage(视觉锚点),再绘制贯穿 block 的弧线,最后绘制 block; + // arc 不绘制 block 之间默认的连接线。direction = 'up' 时 titleImage 贴底(穹顶), + // direction = 'down' 时 titleImage 贴顶(碗形) + if (isArc(spec)) { + const arcTitleImageMark = buildArcTitleImageMark(spec); + const arcMark = buildArcMark(spec); + return [arcTitleImageMark, arcMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; + } + // clock:辐射式信息盘 —— 圆环骨架 + 径向分隔线 + blocks(楔形 + 外圈文字) + if (isClock(spec)) { + const ringsMark = buildClockArcMark(spec); + return [titleImageMark, ringsMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; + } + // wing:椭圆弧脉络 + 弧线上的圆形 image + 左右交替排列的 title/content; + // 通过 layout.direction 控制翅膀朝向('left' | 'right') + if (isWing(spec)) { + const arcMark = buildWingArcMark(spec); + const wingTitleImageMark = buildWingTitleImageMark(spec); + return [arcMark, wingTitleImageMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; + } + return [titleImageMark, lineMark, ...blockMarks].filter(Boolean) as IExtensionGroupMarkSpec[]; +}; + +const buildLineMark = (spec: IStorylineSpec): IExtensionGroupMarkSpec | null => { + if (spec.line?.visible === false || (spec.data?.length ?? 0) <= 1) { + return null; + } + if (isLandscape(spec)) { + return buildLandscapeConnectingCurve(spec); + } + if (isPortrait(spec)) { + return buildPortraitAxisMark(spec); + } + return buildDefaultLineMark(spec); +}; + +const buildBlockMark = (spec: IStorylineSpec, block: IStorylineBlock, index: number): IExtensionGroupMarkSpec => { + if (isLandscape(spec)) { + return buildLandscapeBlockMark(spec, block, index); + } + if (isPortrait(spec)) { + return buildPortraitBlockMark(spec, block, index); + } + if (isArc(spec)) { + return buildArcBlockMark(spec, block, index); + } + if (isClock(spec)) { + return buildClockBlockMark(spec, block, index); + } + if (isWing(spec)) { + return buildWingBlockMark(spec, block, index); + } + return buildDefaultBlockMark(spec, block, index); +}; diff --git a/packages/vchart-extension/src/charts/storyline/storyline.ts b/packages/vchart-extension/src/charts/storyline/storyline.ts new file mode 100644 index 0000000000..8920d55256 --- /dev/null +++ b/packages/vchart-extension/src/charts/storyline/storyline.ts @@ -0,0 +1,61 @@ +import { + BaseChart, + VChart, + registerArcMark, + registerCommonChart, + registerCustomMark, + registerGroupMark, + registerImageMark, + registerLineMark, + registerPathMark, + registerRectMark, + registerTextMark +} from '@visactor/vchart'; +import type { IStorylineSpec } from './interface'; +import { StorylineChartSpecTransformer } from './storyline-transformer'; + +export class StorylineChart extends BaseChart< + Omit +> { + type = 'storyline'; + static type = 'storyline'; + static readonly view: string = 'singleDefault'; + + declare _spec: T; + + static readonly transformerConstructor = StorylineChartSpecTransformer; + readonly transformerConstructor = StorylineChartSpecTransformer; + + init() { + if (!this.isValid()) { + return; + } + super.init(); + } + + protected isValid() { + const { data } = this._spec; + if (!Array.isArray(data)) { + this._option.onError?.('Data is required and should be an array for storyline chart'); + return false; + } + return true; + } +} + +export const registerStorylineChart = (option?: { VChart?: typeof VChart }) => { + registerCommonChart(); + registerCustomMark(); + registerGroupMark(); + registerRectMark(); + registerTextMark(); + registerImageMark(); + registerLineMark(); + registerPathMark(); + registerArcMark(); + + const vchartConstructor = option?.VChart || VChart; + if (vchartConstructor) { + vchartConstructor.useChart([StorylineChart]); + } +}; diff --git a/packages/vchart-extension/src/index.ts b/packages/vchart-extension/src/index.ts index bc546ab45a..d8f80b5d97 100644 --- a/packages/vchart-extension/src/index.ts +++ b/packages/vchart-extension/src/index.ts @@ -18,6 +18,7 @@ export * from './charts/pictogram'; export * from './charts/image-cloud'; export * from './charts/candlestick'; export * from './charts/timeline'; +export * from './charts/storyline'; export * from './components/series-break'; export * from './components/bar-link';