From 1c8bcb40dd4b702f2693c8b08e29699a1c0e3565 Mon Sep 17 00:00:00 2001 From: Xander Dumaine Date: Thu, 7 May 2026 10:49:51 -0400 Subject: [PATCH 1/4] Add legends to XY charts Preserve named line and bar series as legend entries so multi-series XY charts can identify their plotted data. Co-authored-by: Cursor --- .../rendering/xychart/xyChart.spec.js | 20 +++ docs/syntax/xyChart.md | 30 ++++ packages/mermaid/src/config.type.ts | 12 ++ .../xychart/chartBuilder/components/legend.ts | 154 ++++++++++++++++++ .../xychart/chartBuilder/interfaces.ts | 6 + .../xychart/chartBuilder/orchestrator.ts | 23 +++ .../mermaid/src/diagrams/xychart/xychartDb.ts | 2 + packages/mermaid/src/docs/syntax/xyChart.md | 20 +++ .../mermaid/src/schemas/config.schema.yaml | 14 ++ packages/mermaid/src/themes/theme-base.js | 1 + packages/mermaid/src/themes/theme-dark.js | 1 + packages/mermaid/src/themes/theme-default.js | 1 + packages/mermaid/src/themes/theme-forest.js | 1 + packages/mermaid/src/themes/theme-neo-dark.js | 1 + packages/mermaid/src/themes/theme-neo.js | 1 + packages/mermaid/src/themes/theme-neutral.js | 1 + .../mermaid/src/themes/theme-redux-color.js | 1 + .../src/themes/theme-redux-dark-color.js | 1 + .../mermaid/src/themes/theme-redux-dark.js | 1 + packages/mermaid/src/themes/theme-redux.js | 1 + 20 files changed, 292 insertions(+) create mode 100644 packages/mermaid/src/diagrams/xychart/chartBuilder/components/legend.ts diff --git a/cypress/integration/rendering/xychart/xyChart.spec.js b/cypress/integration/rendering/xychart/xyChart.spec.js index f96acbfca7a..adb5e2d8bae 100644 --- a/cypress/integration/rendering/xychart/xyChart.spec.js +++ b/cypress/integration/rendering/xychart/xyChart.spec.js @@ -92,6 +92,26 @@ describe('XY Chart', () => { {} ); }); + it('should render legends for named plots', () => { + renderGraph( + ` + xychart-beta + title "An Example Chart" + x-axis ["90d", "60d", "30d", "7d", "1d", "Current"] + y-axis "Seconds" 0 --> 198.2 + line "avg" [48.1, 41.5, 45.7, 72.8, 67.7, 59.9] + line "p50" [38.2, 36.8, 39.7, 54.5, 49.0, 38.4] + line "p95" [112.2, 75.3, 103.0, 177.0, 180.2, 109.4] + `, + { screenshot: false } + ); + + cy.get('g.legend text').should('have.length', 3); + cy.get('g.legend').should('contain.text', 'avg'); + cy.get('g.legend').should('contain.text', 'p50'); + cy.get('g.legend').should('contain.text', 'p95'); + cy.get('g.legend path').should('have.length', 3); + }); it('Decimals and negative numbers are supported', () => { imgSnapshotTest( ` diff --git a/docs/syntax/xyChart.md b/docs/syntax/xyChart.md index 28712b1c0da..17b50ed5b7e 100644 --- a/docs/syntax/xyChart.md +++ b/docs/syntax/xyChart.md @@ -87,6 +87,7 @@ A line chart offers the capability to graphically depict lines. #### Example 1. `line [2.3, 45, .98, -3.4]` it can have all valid numeric values. +2. `line "series name" [2.3, 45, .98, -3.4]` adds the line to the legend. ### Bar chart @@ -95,6 +96,31 @@ A bar chart offers the capability to graphically depict bars. #### Example 1. `bar [2.3, 45, .98, -3.4]` it can have all valid numeric values. +2. `bar "series name" [2.3, 45, .98, -3.4]` adds the bar to the legend. + +### Legend + +Named line and bar plots are automatically shown in a legend. Unnamed plots are omitted from the legend. + +```mermaid-example +xychart-beta + title "An Example Chart" + x-axis ["90d", "60d", "30d", "7d", "1d", "Current"] + y-axis "Seconds" 0 --> 198.2 + line "avg" [48.1, 41.5, 45.7, 72.8, 67.7, 59.9] + line "p50" [38.2, 36.8, 39.7, 54.5, 49.0, 38.4] + line "p95" [112.2, 75.3, 103.0, 177.0, 180.2, 109.4] +``` + +```mermaid +xychart-beta + title "An Example Chart" + x-axis ["90d", "60d", "30d", "7d", "1d", "Current"] + y-axis "Seconds" 0 --> 198.2 + line "avg" [48.1, 41.5, 45.7, 72.8, 67.7, 59.9] + line "p50" [38.2, 36.8, 39.7, 54.5, 49.0, 38.4] + line "p95" [112.2, 75.3, 103.0, 177.0, 180.2, 109.4] +``` #### Simplest example @@ -114,6 +140,9 @@ xychart | titlePadding | Top and Bottom padding of the title | 10 | | titleFontSize | Title font size | 20 | | showTitle | Title to be shown or not | true | +| showLegend | Legend to be shown for named plots or not | true | +| legendFontSize | Legend font size | 14 | +| legendPadding | Padding around the legend | 10 | | xAxis | xAxis configuration | AxisConfig | | yAxis | yAxis configuration | AxisConfig | | chartOrientation | 'vertical' or 'horizontal' | 'vertical' | @@ -155,6 +184,7 @@ config: | backgroundColor | Background color of the whole chart | | titleColor | Color of the Title text | | dataLabelColor | Color of the Data labels (if shown) | +| legendTextColor | Color of the legend text | | xAxisLabelColor | Color of the x-axis labels | | xAxisTitleColor | Color of the x-axis title | | xAxisTickColor | Color of the x-axis tick | diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index 5fd683bfd88..1cb50f61a6b 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -1027,6 +1027,18 @@ export interface XYChartConfig extends BaseDiagramConfig { * Should show the chart title */ showTitle?: boolean; + /** + * Should show a legend for named plots + */ + showLegend?: boolean; + /** + * Font size of the legend text + */ + legendFontSize?: number; + /** + * Padding around the legend + */ + legendPadding?: number; xAxis?: XYChartAxisConfig; yAxis?: XYChartAxisConfig; /** diff --git a/packages/mermaid/src/diagrams/xychart/chartBuilder/components/legend.ts b/packages/mermaid/src/diagrams/xychart/chartBuilder/components/legend.ts new file mode 100644 index 00000000000..6c9c59f8f7f --- /dev/null +++ b/packages/mermaid/src/diagrams/xychart/chartBuilder/components/legend.ts @@ -0,0 +1,154 @@ +import type { SVGGroup } from '../../../../diagram-api/types.js'; +import type { + ChartComponent, + Dimension, + DrawableElem, + PlotData, + Point, + XYChartConfig, + XYChartData, + XYChartThemeConfig, +} from '../interfaces.js'; +import { isBarPlot } from '../interfaces.js'; +import type { TextDimensionCalculator } from '../textDimensionCalculator.js'; +import { TextDimensionCalculatorWithFont } from '../textDimensionCalculator.js'; + +const LEGEND_MARKER_TO_FONT_RATIO = 0.75; +const LEGEND_ITEM_SPACING_TO_FONT_RATIO = 0.5; +const LEGEND_MARKER_SPACING_TO_FONT_RATIO = 0.35; + +export class ChartLegend implements ChartComponent { + private boundingRect = { x: 0, y: 0, width: 0, height: 0 }; + private visiblePlots: PlotData[] = []; + + constructor( + private textDimensionCalculator: TextDimensionCalculator, + private chartConfig: XYChartConfig, + private chartData: XYChartData, + private chartThemeConfig: XYChartThemeConfig + ) {} + + setBoundingBoxXY(point: Point): void { + this.boundingRect.x = point.x; + this.boundingRect.y = point.y; + } + + calculateSpace(availableSpace: Dimension): Dimension { + this.visiblePlots = this.chartConfig.showLegend + ? this.chartData.plots.filter((plot) => plot.title) + : []; + + if (this.visiblePlots.length === 0) { + this.boundingRect.width = 0; + this.boundingRect.height = 0; + return { width: 0, height: 0 }; + } + + const fontSize = this.chartConfig.legendFontSize; + const markerSize = fontSize * LEGEND_MARKER_TO_FONT_RATIO; + const markerSpacing = fontSize * LEGEND_MARKER_SPACING_TO_FONT_RATIO; + const itemSpacing = fontSize * LEGEND_ITEM_SPACING_TO_FONT_RATIO; + const textDimension = this.textDimensionCalculator.getMaxDimension( + this.visiblePlots.map((plot) => plot.title), + fontSize + ); + + const widthRequired = + this.chartConfig.legendPadding * 2 + markerSize + markerSpacing + textDimension.width; + const heightRequired = + this.chartConfig.legendPadding * 2 + + this.visiblePlots.length * fontSize + + (this.visiblePlots.length - 1) * itemSpacing; + + if (widthRequired <= availableSpace.width && heightRequired <= availableSpace.height) { + this.boundingRect.width = widthRequired; + this.boundingRect.height = heightRequired; + } else { + this.visiblePlots = []; + this.boundingRect.width = 0; + this.boundingRect.height = 0; + } + + return { + width: this.boundingRect.width, + height: this.boundingRect.height, + }; + } + + getDrawableElements(): DrawableElem[] { + if (this.visiblePlots.length === 0) { + return []; + } + + const fontSize = this.chartConfig.legendFontSize; + const markerSize = fontSize * LEGEND_MARKER_TO_FONT_RATIO; + const markerSpacing = fontSize * LEGEND_MARKER_SPACING_TO_FONT_RATIO; + const itemSpacing = fontSize * LEGEND_ITEM_SPACING_TO_FONT_RATIO; + const rowHeight = fontSize + itemSpacing; + const startX = this.boundingRect.x + this.chartConfig.legendPadding; + const startY = this.boundingRect.y + this.chartConfig.legendPadding; + + return [ + { + groupTexts: ['legend', 'markers'], + type: 'rect', + data: this.visiblePlots.flatMap((plot, index) => + isBarPlot(plot) + ? [ + { + x: startX, + y: startY + index * rowHeight, + width: markerSize, + height: markerSize, + fill: plot.fill, + strokeFill: plot.fill, + strokeWidth: 0, + }, + ] + : [] + ), + }, + { + groupTexts: ['legend', 'markers'], + type: 'path', + data: this.visiblePlots.flatMap((plot, index) => { + if (isBarPlot(plot)) { + return []; + } + const markerY = startY + index * rowHeight + markerSize / 2; + return [ + { + path: `M ${startX},${markerY} L ${startX + markerSize},${markerY}`, + strokeFill: plot.strokeFill, + strokeWidth: plot.strokeWidth, + }, + ]; + }), + }, + { + groupTexts: ['legend', 'label'], + type: 'text', + data: this.visiblePlots.map((plot, index) => ({ + text: plot.title, + x: startX + markerSize + markerSpacing, + y: startY + index * rowHeight + markerSize / 2, + fill: this.chartThemeConfig.legendTextColor, + fontSize, + rotation: 0, + verticalPos: 'middle', + horizontalPos: 'left', + })), + }, + ]; + } +} + +export function getChartLegendComponent( + chartConfig: XYChartConfig, + chartData: XYChartData, + chartThemeConfig: XYChartThemeConfig, + tmpSVGGroup: SVGGroup +): ChartComponent { + const textDimensionCalculator = new TextDimensionCalculatorWithFont(tmpSVGGroup); + return new ChartLegend(textDimensionCalculator, chartConfig, chartData, chartThemeConfig); +} diff --git a/packages/mermaid/src/diagrams/xychart/chartBuilder/interfaces.ts b/packages/mermaid/src/diagrams/xychart/chartBuilder/interfaces.ts index 7ba4318d154..661d9178fc2 100644 --- a/packages/mermaid/src/diagrams/xychart/chartBuilder/interfaces.ts +++ b/packages/mermaid/src/diagrams/xychart/chartBuilder/interfaces.ts @@ -9,6 +9,7 @@ export interface XYChartThemeConfig { backgroundColor: string; titleColor: string; dataLabelColor: string; + legendTextColor: string; xAxisLabelColor: string; xAxisTitleColor: string; xAxisTickColor: string; @@ -30,6 +31,7 @@ export type SimplePlotDataType = [string, number][]; export interface LinePlotData { type: 'line'; + title: string; strokeFill: string; strokeWidth: number; data: SimplePlotDataType; @@ -37,6 +39,7 @@ export interface LinePlotData { export interface BarPlotData { type: 'bar'; + title: string; fill: string; data: SimplePlotDataType; } @@ -94,6 +97,9 @@ export interface XYChartConfig { titleFontSize: number; titlePadding: number; showTitle: boolean; + showLegend: boolean; + legendFontSize: number; + legendPadding: number; showDataLabel: boolean; showDataLabelOutsideBar: boolean; xAxis: XYChartAxisConfig; diff --git a/packages/mermaid/src/diagrams/xychart/chartBuilder/orchestrator.ts b/packages/mermaid/src/diagrams/xychart/chartBuilder/orchestrator.ts index 8809efe265e..651b7d10add 100644 --- a/packages/mermaid/src/diagrams/xychart/chartBuilder/orchestrator.ts +++ b/packages/mermaid/src/diagrams/xychart/chartBuilder/orchestrator.ts @@ -2,6 +2,7 @@ import type { SVGGroup } from '../../../diagram-api/types.js'; import type { Axis } from './components/axis/index.js'; import { getAxis } from './components/axis/index.js'; import { getChartTitleComponent } from './components/chartTitle.js'; +import { getChartLegendComponent } from './components/legend.js'; import type { Plot } from './components/plot/index.js'; import { getPlotComponent } from './components/plot/index.js'; import type { @@ -19,6 +20,7 @@ export class Orchestrator { plot: Plot; xAxis: Axis; yAxis: Axis; + legend: ChartComponent; }; constructor( private chartConfig: XYChartConfig, @@ -29,6 +31,7 @@ export class Orchestrator { this.componentStore = { title: getChartTitleComponent(chartConfig, chartData, chartThemeConfig, tmpSVGGroup), plot: getPlotComponent(chartConfig, chartData, chartThemeConfig), + legend: getChartLegendComponent(chartConfig, chartData, chartThemeConfig, tmpSVGGroup), xAxis: getAxis( chartData.xAxis, chartConfig.xAxis, @@ -59,6 +62,7 @@ export class Orchestrator { let availableHeight = this.chartConfig.height; let plotX = 0; let plotY = 0; + let legendSpace = { width: 0, height: 0 }; let chartWidth = Math.floor((availableWidth * this.chartConfig.plotReservedSpacePercent) / 100); let chartHeight = Math.floor( (availableHeight * this.chartConfig.plotReservedSpacePercent) / 100 @@ -89,6 +93,11 @@ export class Orchestrator { }); plotX = spaceUsed.width; availableWidth -= spaceUsed.width; + legendSpace = this.componentStore.legend.calculateSpace({ + width: availableWidth, + height: chartHeight, + }); + availableWidth -= legendSpace.width; if (availableWidth > 0) { chartWidth += availableWidth; availableWidth = 0; @@ -103,6 +112,10 @@ export class Orchestrator { }); this.componentStore.plot.setBoundingBoxXY({ x: plotX, y: plotY }); + this.componentStore.legend.setBoundingBoxXY({ + x: plotX + chartWidth, + y: plotY + Math.max((chartHeight - legendSpace.height) / 2, 0), + }); this.componentStore.xAxis.setRange([plotX, plotX + chartWidth]); this.componentStore.xAxis.setBoundingBoxXY({ x: plotX, y: plotY + chartHeight }); this.componentStore.yAxis.setRange([plotY, plotY + chartHeight]); @@ -118,6 +131,7 @@ export class Orchestrator { let titleYEnd = 0; let plotX = 0; let plotY = 0; + let legendSpace = { width: 0, height: 0 }; let chartWidth = Math.floor((availableWidth * this.chartConfig.plotReservedSpacePercent) / 100); let chartHeight = Math.floor( (availableHeight * this.chartConfig.plotReservedSpacePercent) / 100 @@ -149,6 +163,11 @@ export class Orchestrator { }); availableHeight -= spaceUsed.height; plotY = titleYEnd + spaceUsed.height; + legendSpace = this.componentStore.legend.calculateSpace({ + width: availableWidth, + height: chartHeight, + }); + availableWidth -= legendSpace.width; if (availableWidth > 0) { chartWidth += availableWidth; availableWidth = 0; @@ -163,6 +182,10 @@ export class Orchestrator { }); this.componentStore.plot.setBoundingBoxXY({ x: plotX, y: plotY }); + this.componentStore.legend.setBoundingBoxXY({ + x: plotX + chartWidth, + y: plotY + Math.max((chartHeight - legendSpace.height) / 2, 0), + }); this.componentStore.yAxis.setRange([plotX, plotX + chartWidth]); this.componentStore.yAxis.setBoundingBoxXY({ x: plotX, y: titleYEnd }); this.componentStore.xAxis.setRange([plotY, plotY + chartHeight]); diff --git a/packages/mermaid/src/diagrams/xychart/xychartDb.ts b/packages/mermaid/src/diagrams/xychart/xychartDb.ts index 9ad7cd420cf..fe3863b4626 100644 --- a/packages/mermaid/src/diagrams/xychart/xychartDb.ts +++ b/packages/mermaid/src/diagrams/xychart/xychartDb.ts @@ -162,6 +162,7 @@ function setLineData(title: NormalTextType, data: number[]) { const plotData = transformDataWithoutCategory(data); xyChartData.plots.push({ type: 'line', + title: textSanitizer(title.text), strokeFill: getPlotColorFromPalette(plotIndex), strokeWidth: 2, data: plotData, @@ -173,6 +174,7 @@ function setBarData(title: NormalTextType, data: number[]) { const plotData = transformDataWithoutCategory(data); xyChartData.plots.push({ type: 'bar', + title: textSanitizer(title.text), fill: getPlotColorFromPalette(plotIndex), data: plotData, }); diff --git a/packages/mermaid/src/docs/syntax/xyChart.md b/packages/mermaid/src/docs/syntax/xyChart.md index d0fdbd112fd..2dbfa3cc506 100644 --- a/packages/mermaid/src/docs/syntax/xyChart.md +++ b/packages/mermaid/src/docs/syntax/xyChart.md @@ -75,6 +75,7 @@ A line chart offers the capability to graphically depict lines. #### Example 1. `line [2.3, 45, .98, -3.4]` it can have all valid numeric values. +2. `line "series name" [2.3, 45, .98, -3.4]` adds the line to the legend. ### Bar chart @@ -83,6 +84,21 @@ A bar chart offers the capability to graphically depict bars. #### Example 1. `bar [2.3, 45, .98, -3.4]` it can have all valid numeric values. +2. `bar "series name" [2.3, 45, .98, -3.4]` adds the bar to the legend. + +### Legend + +Named line and bar plots are automatically shown in a legend. Unnamed plots are omitted from the legend. + +```mermaid-example +xychart-beta + title "An Example Chart" + x-axis ["90d", "60d", "30d", "7d", "1d", "Current"] + y-axis "Seconds" 0 --> 198.2 + line "avg" [48.1, 41.5, 45.7, 72.8, 67.7, 59.9] + line "p50" [38.2, 36.8, 39.7, 54.5, 49.0, 38.4] + line "p95" [112.2, 75.3, 103.0, 177.0, 180.2, 109.4] +``` #### Simplest example @@ -102,6 +118,9 @@ xychart | titlePadding | Top and Bottom padding of the title | 10 | | titleFontSize | Title font size | 20 | | showTitle | Title to be shown or not | true | +| showLegend | Legend to be shown for named plots or not | true | +| legendFontSize | Legend font size | 14 | +| legendPadding | Padding around the legend | 10 | | xAxis | xAxis configuration | AxisConfig | | yAxis | yAxis configuration | AxisConfig | | chartOrientation | 'vertical' or 'horizontal' | 'vertical' | @@ -143,6 +162,7 @@ config: | backgroundColor | Background color of the whole chart | | titleColor | Color of the Title text | | dataLabelColor | Color of the Data labels (if shown) | +| legendTextColor | Color of the legend text | | xAxisLabelColor | Color of the x-axis labels | | xAxisTitleColor | Color of the x-axis title | | xAxisTickColor | Color of the x-axis tick | diff --git a/packages/mermaid/src/schemas/config.schema.yaml b/packages/mermaid/src/schemas/config.schema.yaml index 5df1238f60c..1246786a0f6 100644 --- a/packages/mermaid/src/schemas/config.schema.yaml +++ b/packages/mermaid/src/schemas/config.schema.yaml @@ -1343,6 +1343,20 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file) description: Should show the chart title type: boolean default: true + showLegend: + description: Should show a legend for named plots + type: boolean + default: true + legendFontSize: + description: Font size of the legend text + type: number + default: 14 + minimum: 1 + legendPadding: + description: Padding around the legend + type: number + default: 10 + minimum: 0 xAxis: $ref: '#/$defs/XYChartAxisConfig' default: { '$ref': '#/$defs/XYChartAxisConfig' } diff --git a/packages/mermaid/src/themes/theme-base.js b/packages/mermaid/src/themes/theme-base.js index 314aebc3a94..96de056d0fd 100644 --- a/packages/mermaid/src/themes/theme-base.js +++ b/packages/mermaid/src/themes/theme-base.js @@ -336,6 +336,7 @@ class Theme { backgroundColor: this.xyChart?.backgroundColor || this.background, titleColor: this.xyChart?.titleColor || this.primaryTextColor, dataLabelColor: this.xyChart?.dataLabelColor || this.primaryTextColor, + legendTextColor: this.xyChart?.legendTextColor || this.primaryTextColor, xAxisTitleColor: this.xyChart?.xAxisTitleColor || this.primaryTextColor, xAxisLabelColor: this.xyChart?.xAxisLabelColor || this.primaryTextColor, xAxisTickColor: this.xyChart?.xAxisTickColor || this.primaryTextColor, diff --git a/packages/mermaid/src/themes/theme-dark.js b/packages/mermaid/src/themes/theme-dark.js index 4f4a361b1bc..28af0f9b309 100644 --- a/packages/mermaid/src/themes/theme-dark.js +++ b/packages/mermaid/src/themes/theme-dark.js @@ -307,6 +307,7 @@ class Theme { backgroundColor: this.xyChart?.backgroundColor || this.background, titleColor: this.xyChart?.titleColor || this.primaryTextColor, dataLabelColor: this.xyChart?.dataLabelColor || this.primaryTextColor, + legendTextColor: this.xyChart?.legendTextColor || this.primaryTextColor, xAxisTitleColor: this.xyChart?.xAxisTitleColor || this.primaryTextColor, xAxisLabelColor: this.xyChart?.xAxisLabelColor || this.primaryTextColor, xAxisTickColor: this.xyChart?.xAxisTickColor || this.primaryTextColor, diff --git a/packages/mermaid/src/themes/theme-default.js b/packages/mermaid/src/themes/theme-default.js index 9bed4594ac3..849c1d98f54 100644 --- a/packages/mermaid/src/themes/theme-default.js +++ b/packages/mermaid/src/themes/theme-default.js @@ -371,6 +371,7 @@ class Theme { backgroundColor: this.xyChart?.backgroundColor || this.background, titleColor: this.xyChart?.titleColor || this.primaryTextColor, dataLabelColor: this.xyChart?.dataLabelColor || this.primaryTextColor, + legendTextColor: this.xyChart?.legendTextColor || this.primaryTextColor, xAxisTitleColor: this.xyChart?.xAxisTitleColor || this.primaryTextColor, xAxisLabelColor: this.xyChart?.xAxisLabelColor || this.primaryTextColor, xAxisTickColor: this.xyChart?.xAxisTickColor || this.primaryTextColor, diff --git a/packages/mermaid/src/themes/theme-forest.js b/packages/mermaid/src/themes/theme-forest.js index 454890a61ac..36c14cadb86 100644 --- a/packages/mermaid/src/themes/theme-forest.js +++ b/packages/mermaid/src/themes/theme-forest.js @@ -343,6 +343,7 @@ class Theme { backgroundColor: this.xyChart?.backgroundColor || this.background, titleColor: this.xyChart?.titleColor || this.primaryTextColor, dataLabelColor: this.xyChart?.dataLabelColor || this.primaryTextColor, + legendTextColor: this.xyChart?.legendTextColor || this.primaryTextColor, xAxisTitleColor: this.xyChart?.xAxisTitleColor || this.primaryTextColor, xAxisLabelColor: this.xyChart?.xAxisLabelColor || this.primaryTextColor, xAxisTickColor: this.xyChart?.xAxisTickColor || this.primaryTextColor, diff --git a/packages/mermaid/src/themes/theme-neo-dark.js b/packages/mermaid/src/themes/theme-neo-dark.js index 58d59b9e9fe..9a8641ca653 100644 --- a/packages/mermaid/src/themes/theme-neo-dark.js +++ b/packages/mermaid/src/themes/theme-neo-dark.js @@ -297,6 +297,7 @@ class Theme { this.xyChart = { backgroundColor: this.xyChart?.backgroundColor || this.background, titleColor: this.xyChart?.titleColor || this.primaryTextColor, + legendTextColor: this.xyChart?.legendTextColor || this.primaryTextColor, xAxisTitleColor: this.xyChart?.xAxisTitleColor || this.primaryTextColor, xAxisLabelColor: this.xyChart?.xAxisLabelColor || this.primaryTextColor, xAxisTickColor: this.xyChart?.xAxisTickColor || this.primaryTextColor, diff --git a/packages/mermaid/src/themes/theme-neo.js b/packages/mermaid/src/themes/theme-neo.js index 1624c131d83..de6cd8bd0be 100644 --- a/packages/mermaid/src/themes/theme-neo.js +++ b/packages/mermaid/src/themes/theme-neo.js @@ -284,6 +284,7 @@ class Theme { this.xyChart = { backgroundColor: this.xyChart?.backgroundColor || this.background, titleColor: this.xyChart?.titleColor || this.primaryTextColor, + legendTextColor: this.xyChart?.legendTextColor || this.primaryTextColor, xAxisTitleColor: this.xyChart?.xAxisTitleColor || this.primaryTextColor, xAxisLabelColor: this.xyChart?.xAxisLabelColor || this.primaryTextColor, xAxisTickColor: this.xyChart?.xAxisTickColor || this.primaryTextColor, diff --git a/packages/mermaid/src/themes/theme-neutral.js b/packages/mermaid/src/themes/theme-neutral.js index e0a0209e8a8..887540f8ced 100644 --- a/packages/mermaid/src/themes/theme-neutral.js +++ b/packages/mermaid/src/themes/theme-neutral.js @@ -328,6 +328,7 @@ class Theme { backgroundColor: this.xyChart?.backgroundColor || this.background, titleColor: this.xyChart?.titleColor || this.primaryTextColor, dataLabelColor: this.xyChart?.dataLabelColor || this.primaryTextColor, + legendTextColor: this.xyChart?.legendTextColor || this.primaryTextColor, xAxisTitleColor: this.xyChart?.xAxisTitleColor || this.primaryTextColor, xAxisLabelColor: this.xyChart?.xAxisLabelColor || this.primaryTextColor, xAxisTickColor: this.xyChart?.xAxisTickColor || this.primaryTextColor, diff --git a/packages/mermaid/src/themes/theme-redux-color.js b/packages/mermaid/src/themes/theme-redux-color.js index 23da2973a33..96ac9d14cbc 100644 --- a/packages/mermaid/src/themes/theme-redux-color.js +++ b/packages/mermaid/src/themes/theme-redux-color.js @@ -320,6 +320,7 @@ class Theme { this.xyChart = { backgroundColor: this.xyChart?.backgroundColor || this.background, titleColor: this.xyChart?.titleColor || this.primaryTextColor, + legendTextColor: this.xyChart?.legendTextColor || this.primaryTextColor, xAxisTitleColor: this.xyChart?.xAxisTitleColor || this.primaryTextColor, xAxisLabelColor: this.xyChart?.xAxisLabelColor || this.primaryTextColor, xAxisTickColor: this.xyChart?.xAxisTickColor || this.primaryTextColor, diff --git a/packages/mermaid/src/themes/theme-redux-dark-color.js b/packages/mermaid/src/themes/theme-redux-dark-color.js index 7e1a2dfa458..bbfa3bb6830 100644 --- a/packages/mermaid/src/themes/theme-redux-dark-color.js +++ b/packages/mermaid/src/themes/theme-redux-dark-color.js @@ -329,6 +329,7 @@ class Theme { this.xyChart = { backgroundColor: this.xyChart?.backgroundColor || this.background, titleColor: this.xyChart?.titleColor || this.primaryTextColor, + legendTextColor: this.xyChart?.legendTextColor || this.primaryTextColor, xAxisTitleColor: this.xyChart?.xAxisTitleColor || this.primaryTextColor, xAxisLabelColor: this.xyChart?.xAxisLabelColor || this.primaryTextColor, xAxisTickColor: this.xyChart?.xAxisTickColor || this.primaryTextColor, diff --git a/packages/mermaid/src/themes/theme-redux-dark.js b/packages/mermaid/src/themes/theme-redux-dark.js index 5a018da1553..ab0258cc240 100644 --- a/packages/mermaid/src/themes/theme-redux-dark.js +++ b/packages/mermaid/src/themes/theme-redux-dark.js @@ -312,6 +312,7 @@ class Theme { this.xyChart = { backgroundColor: this.xyChart?.backgroundColor || this.background, titleColor: this.xyChart?.titleColor || this.primaryTextColor, + legendTextColor: this.xyChart?.legendTextColor || this.primaryTextColor, xAxisTitleColor: this.xyChart?.xAxisTitleColor || this.primaryTextColor, xAxisLabelColor: this.xyChart?.xAxisLabelColor || this.primaryTextColor, xAxisTickColor: this.xyChart?.xAxisTickColor || this.primaryTextColor, diff --git a/packages/mermaid/src/themes/theme-redux.js b/packages/mermaid/src/themes/theme-redux.js index 5f844d78f85..af86d40d345 100644 --- a/packages/mermaid/src/themes/theme-redux.js +++ b/packages/mermaid/src/themes/theme-redux.js @@ -285,6 +285,7 @@ class Theme { this.xyChart = { backgroundColor: this.xyChart?.backgroundColor || this.background, titleColor: this.xyChart?.titleColor || this.primaryTextColor, + legendTextColor: this.xyChart?.legendTextColor || this.primaryTextColor, xAxisTitleColor: this.xyChart?.xAxisTitleColor || this.primaryTextColor, xAxisLabelColor: this.xyChart?.xAxisLabelColor || this.primaryTextColor, xAxisTickColor: this.xyChart?.xAxisTickColor || this.primaryTextColor, From 0fd7a9fe0d10a1ac39359bc5cb5341b5010a624e Mon Sep 17 00:00:00 2001 From: Xander Dumaine Date: Thu, 7 May 2026 10:54:50 -0400 Subject: [PATCH 2/4] Document XY chart legend release notes Follow Mermaid contribution guidance by marking the new docs section with the release token and adding a changeset. Co-authored-by: Cursor --- .changeset/xychart-legends.md | 5 +++++ docs/syntax/xyChart.md | 2 +- packages/mermaid/src/docs/syntax/xyChart.md | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/xychart-legends.md diff --git a/.changeset/xychart-legends.md b/.changeset/xychart-legends.md new file mode 100644 index 00000000000..2cbd4e12e0a --- /dev/null +++ b/.changeset/xychart-legends.md @@ -0,0 +1,5 @@ +--- +'mermaid': minor +--- + +feat: add legends for named XY chart line and bar series diff --git a/docs/syntax/xyChart.md b/docs/syntax/xyChart.md index 17b50ed5b7e..4a014de0905 100644 --- a/docs/syntax/xyChart.md +++ b/docs/syntax/xyChart.md @@ -98,7 +98,7 @@ A bar chart offers the capability to graphically depict bars. 1. `bar [2.3, 45, .98, -3.4]` it can have all valid numeric values. 2. `bar "series name" [2.3, 45, .98, -3.4]` adds the bar to the legend. -### Legend +### Legend (v\+) Named line and bar plots are automatically shown in a legend. Unnamed plots are omitted from the legend. diff --git a/packages/mermaid/src/docs/syntax/xyChart.md b/packages/mermaid/src/docs/syntax/xyChart.md index 2dbfa3cc506..fe9d120d61b 100644 --- a/packages/mermaid/src/docs/syntax/xyChart.md +++ b/packages/mermaid/src/docs/syntax/xyChart.md @@ -86,7 +86,7 @@ A bar chart offers the capability to graphically depict bars. 1. `bar [2.3, 45, .98, -3.4]` it can have all valid numeric values. 2. `bar "series name" [2.3, 45, .98, -3.4]` adds the bar to the legend. -### Legend +### Legend (v+) Named line and bar plots are automatically shown in a legend. Unnamed plots are omitted from the legend. From 3934d7c49034146f6b1969ee6628e236b135ac1e Mon Sep 17 00:00:00 2001 From: Xander Dumaine Date: Thu, 7 May 2026 11:30:39 -0400 Subject: [PATCH 3/4] Test XY chart legend coverage Add focused unit coverage for legend rendering, builder layout integration, plot title persistence, and theme defaults. Co-authored-by: Cursor --- .../chartBuilder/components/legend.spec.ts | 213 ++++++++++++++++++ .../xychart/chartBuilder/index.spec.ts | 154 +++++++++++++ .../src/diagrams/xychart/xychartDb.spec.ts | 33 +++ .../mermaid/src/themes/theme-xychart.spec.js | 46 ++++ 4 files changed, 446 insertions(+) create mode 100644 packages/mermaid/src/diagrams/xychart/chartBuilder/components/legend.spec.ts create mode 100644 packages/mermaid/src/diagrams/xychart/chartBuilder/index.spec.ts create mode 100644 packages/mermaid/src/diagrams/xychart/xychartDb.spec.ts create mode 100644 packages/mermaid/src/themes/theme-xychart.spec.js diff --git a/packages/mermaid/src/diagrams/xychart/chartBuilder/components/legend.spec.ts b/packages/mermaid/src/diagrams/xychart/chartBuilder/components/legend.spec.ts new file mode 100644 index 00000000000..ae3c126bb26 --- /dev/null +++ b/packages/mermaid/src/diagrams/xychart/chartBuilder/components/legend.spec.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest'; +import { ChartLegend } from './legend.js'; +import type { XYChartConfig, XYChartData, XYChartThemeConfig } from '../interfaces.js'; +import type { TextDimensionCalculator } from '../textDimensionCalculator.js'; + +const textDimensionCalculator: TextDimensionCalculator = { + getMaxDimension: (texts, fontSize) => ({ + width: Math.max(...texts.map((text) => text.length)) * fontSize, + height: fontSize, + }), +}; + +const chartConfig: XYChartConfig = { + width: 700, + height: 500, + titleFontSize: 20, + titlePadding: 10, + showTitle: true, + showLegend: true, + legendFontSize: 14, + legendPadding: 10, + showDataLabel: false, + showDataLabelOutsideBar: false, + chartOrientation: 'vertical', + plotReservedSpacePercent: 50, + xAxis: { + showLabel: true, + labelFontSize: 14, + labelPadding: 5, + showTitle: true, + titleFontSize: 16, + titlePadding: 5, + showTick: true, + tickLength: 5, + tickWidth: 2, + showAxisLine: true, + axisLineWidth: 2, + }, + yAxis: { + showLabel: true, + labelFontSize: 14, + labelPadding: 5, + showTitle: true, + titleFontSize: 16, + titlePadding: 5, + showTick: true, + tickLength: 5, + tickWidth: 2, + showAxisLine: true, + axisLineWidth: 2, + }, +}; + +const chartThemeConfig: XYChartThemeConfig = { + backgroundColor: '#fff', + titleColor: '#111', + dataLabelColor: '#222', + legendTextColor: '#333', + xAxisLabelColor: '#444', + xAxisTitleColor: '#555', + xAxisTickColor: '#666', + xAxisLineColor: '#777', + yAxisLabelColor: '#888', + yAxisTitleColor: '#999', + yAxisTickColor: '#aaa', + yAxisLineColor: '#bbb', + plotColorPalette: '#f00,#0f0', +}; + +const chartData: XYChartData = { + title: 'Latency', + xAxis: { + type: 'band', + title: '', + categories: ['90d', '60d'], + }, + yAxis: { + type: 'linear', + title: 'Seconds', + min: 0, + max: 100, + }, + plots: [ + { + type: 'line', + title: 'avg', + strokeFill: '#f00', + strokeWidth: 2, + data: [ + ['90d', 40], + ['60d', 50], + ], + }, + { + type: 'bar', + title: 'p95', + fill: '#0f0', + data: [ + ['90d', 80], + ['60d', 90], + ], + }, + { + type: 'line', + title: '', + strokeFill: '#00f', + strokeWidth: 2, + data: [ + ['90d', 30], + ['60d', 35], + ], + }, + ], +}; + +describe('ChartLegend', () => { + it('renders marker and label drawables for named line and bar plots', () => { + const legend = new ChartLegend( + textDimensionCalculator, + chartConfig, + chartData, + chartThemeConfig + ); + + expect(legend.calculateSpace({ width: 200, height: 200 })).toEqual({ + width: 77.4, + height: 55, + }); + + legend.setBoundingBoxXY({ x: 100, y: 50 }); + const drawables = legend.getDrawableElements(); + + expect(drawables).toHaveLength(3); + expect(drawables[0]).toMatchObject({ + groupTexts: ['legend', 'markers'], + type: 'rect', + data: [ + { + x: 110, + y: 81, + width: 10.5, + height: 10.5, + fill: '#0f0', + strokeFill: '#0f0', + strokeWidth: 0, + }, + ], + }); + expect(drawables[1]).toMatchObject({ + groupTexts: ['legend', 'markers'], + type: 'path', + data: [ + { + path: 'M 110,65.25 L 120.5,65.25', + strokeFill: '#f00', + strokeWidth: 2, + }, + ], + }); + expect(drawables[2]).toMatchObject({ + groupTexts: ['legend', 'label'], + type: 'text', + data: [ + { + text: 'avg', + x: 125.4, + y: 65.25, + fill: '#333', + fontSize: 14, + rotation: 0, + verticalPos: 'middle', + horizontalPos: 'left', + }, + { + text: 'p95', + x: 125.4, + y: 86.25, + fill: '#333', + fontSize: 14, + rotation: 0, + verticalPos: 'middle', + horizontalPos: 'left', + }, + ], + }); + }); + + it('does not render when legends are disabled or no named plots fit', () => { + const disabledLegend = new ChartLegend( + textDimensionCalculator, + { ...chartConfig, showLegend: false }, + chartData, + chartThemeConfig + ); + expect(disabledLegend.calculateSpace({ width: 200, height: 200 })).toEqual({ + width: 0, + height: 0, + }); + expect(disabledLegend.getDrawableElements()).toEqual([]); + + const crampedLegend = new ChartLegend( + textDimensionCalculator, + chartConfig, + chartData, + chartThemeConfig + ); + expect(crampedLegend.calculateSpace({ width: 20, height: 20 })).toEqual({ + width: 0, + height: 0, + }); + expect(crampedLegend.getDrawableElements()).toEqual([]); + }); +}); diff --git a/packages/mermaid/src/diagrams/xychart/chartBuilder/index.spec.ts b/packages/mermaid/src/diagrams/xychart/chartBuilder/index.spec.ts new file mode 100644 index 00000000000..9357af9f4eb --- /dev/null +++ b/packages/mermaid/src/diagrams/xychart/chartBuilder/index.spec.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import { XYChartBuilder } from './index.js'; +import type { SVGGroup } from '../../../diagram-api/types.js'; +import type { XYChartConfig, XYChartData, XYChartThemeConfig } from './interfaces.js'; + +const chartConfig: XYChartConfig = { + width: 700, + height: 500, + titleFontSize: 20, + titlePadding: 10, + showTitle: true, + showLegend: true, + legendFontSize: 14, + legendPadding: 10, + showDataLabel: false, + showDataLabelOutsideBar: false, + chartOrientation: 'vertical', + plotReservedSpacePercent: 50, + xAxis: { + showLabel: true, + labelFontSize: 14, + labelPadding: 5, + showTitle: true, + titleFontSize: 16, + titlePadding: 5, + showTick: true, + tickLength: 5, + tickWidth: 2, + showAxisLine: true, + axisLineWidth: 2, + }, + yAxis: { + showLabel: true, + labelFontSize: 14, + labelPadding: 5, + showTitle: true, + titleFontSize: 16, + titlePadding: 5, + showTick: true, + tickLength: 5, + tickWidth: 2, + showAxisLine: true, + axisLineWidth: 2, + }, +}; + +const chartThemeConfig: XYChartThemeConfig = { + backgroundColor: '#fff', + titleColor: '#111', + dataLabelColor: '#222', + legendTextColor: '#333', + xAxisLabelColor: '#444', + xAxisTitleColor: '#555', + xAxisTickColor: '#666', + xAxisLineColor: '#777', + yAxisLabelColor: '#888', + yAxisTitleColor: '#999', + yAxisTickColor: '#aaa', + yAxisLineColor: '#bbb', + plotColorPalette: '#f00,#0f0', +}; + +const chartData: XYChartData = { + title: 'An Example Chart', + xAxis: { + type: 'band', + title: '', + categories: ['90d', '60d', '30d'], + }, + yAxis: { + type: 'linear', + title: 'Seconds', + min: 0, + max: 200, + }, + plots: [ + { + type: 'line', + title: 'avg', + strokeFill: '#f00', + strokeWidth: 2, + data: [ + ['90d', 48.1], + ['60d', 41.5], + ['30d', 45.7], + ], + }, + { + type: 'bar', + title: 'p95', + fill: '#0f0', + data: [ + ['90d', 112.2], + ['60d', 75.3], + ['30d', 103.0], + ], + }, + ], +}; + +describe('XYChartBuilder', () => { + it('includes legend drawables when named plots are rendered', () => { + const drawables = XYChartBuilder.build( + chartConfig, + chartData, + chartThemeConfig, + undefined as unknown as SVGGroup + ); + + const legendLabels = drawables.find( + (drawable) => drawable.type === 'text' && drawable.groupTexts.join('.') === 'legend.label' + ); + const legendLineMarkers = drawables.find( + (drawable) => drawable.type === 'path' && drawable.groupTexts.join('.') === 'legend.markers' + ); + const legendBarMarkers = drawables.find( + (drawable) => drawable.type === 'rect' && drawable.groupTexts.join('.') === 'legend.markers' + ); + + expect(legendLabels?.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ text: 'avg', fill: '#333' }), + expect.objectContaining({ text: 'p95', fill: '#333' }), + ]) + ); + expect(legendLineMarkers?.data).toEqual([ + expect.objectContaining({ strokeFill: '#f00', strokeWidth: 2 }), + ]); + expect(legendBarMarkers?.data).toEqual([ + expect.objectContaining({ fill: '#0f0', strokeFill: '#0f0' }), + ]); + }); + + it('positions horizontal chart legends beside the plot', () => { + const drawables = XYChartBuilder.build( + { ...chartConfig, chartOrientation: 'horizontal' }, + chartData, + chartThemeConfig, + undefined as unknown as SVGGroup + ); + + const legendLabels = drawables.find( + (drawable) => drawable.type === 'text' && drawable.groupTexts.join('.') === 'legend.label' + ); + + expect(legendLabels?.data[0]).toEqual( + expect.objectContaining({ + text: 'avg', + horizontalPos: 'left', + verticalPos: 'middle', + }) + ); + }); +}); diff --git a/packages/mermaid/src/diagrams/xychart/xychartDb.spec.ts b/packages/mermaid/src/diagrams/xychart/xychartDb.spec.ts new file mode 100644 index 00000000000..1a56fdb6293 --- /dev/null +++ b/packages/mermaid/src/diagrams/xychart/xychartDb.spec.ts @@ -0,0 +1,33 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import db from './xychartDb.js'; + +describe('xychartDb', () => { + beforeEach(() => { + db.clear(); + }); + + afterEach(() => { + db.clear(); + }); + + it('preserves sanitized line and bar titles for legends', () => { + db.setXAxisBand([ + { type: 'text', text: '90d' }, + { type: 'text', text: '60d' }, + ]); + + db.setLineData({ type: 'text', text: ' avg ' }, [48.1, 41.5]); + db.setBarData({ type: 'text', text: ' p95 ' }, [112.2, 75.3]); + + expect(db.getXYChartData().plots).toEqual([ + expect.objectContaining({ + type: 'line', + title: 'avg', + }), + expect.objectContaining({ + type: 'bar', + title: 'p95', + }), + ]); + }); +}); diff --git a/packages/mermaid/src/themes/theme-xychart.spec.js b/packages/mermaid/src/themes/theme-xychart.spec.js new file mode 100644 index 00000000000..f2113eb05da --- /dev/null +++ b/packages/mermaid/src/themes/theme-xychart.spec.js @@ -0,0 +1,46 @@ +import { getThemeVariables as getBaseThemeVariables } from './theme-base.js'; +import { getThemeVariables as getDarkThemeVariables } from './theme-dark.js'; +import { getThemeVariables as getDefaultThemeVariables } from './theme-default.js'; +import { getThemeVariables as getForestThemeVariables } from './theme-forest.js'; +import { getThemeVariables as getNeoDarkThemeVariables } from './theme-neo-dark.js'; +import { getThemeVariables as getNeoThemeVariables } from './theme-neo.js'; +import { getThemeVariables as getNeutralThemeVariables } from './theme-neutral.js'; +import { getThemeVariables as getReduxColorThemeVariables } from './theme-redux-color.js'; +import { getThemeVariables as getReduxDarkColorThemeVariables } from './theme-redux-dark-color.js'; +import { getThemeVariables as getReduxDarkThemeVariables } from './theme-redux-dark.js'; +import { getThemeVariables as getReduxThemeVariables } from './theme-redux.js'; + +const themes = [ + ['base', getBaseThemeVariables], + ['dark', getDarkThemeVariables], + ['default', getDefaultThemeVariables], + ['forest', getForestThemeVariables], + ['neo-dark', getNeoDarkThemeVariables], + ['neo', getNeoThemeVariables], + ['neutral', getNeutralThemeVariables], + ['redux-color', getReduxColorThemeVariables], + ['redux-dark-color', getReduxDarkColorThemeVariables], + ['redux-dark', getReduxDarkThemeVariables], + ['redux', getReduxThemeVariables], +]; + +describe('xychart theme variables', () => { + it.each(themes)( + '%s defaults legend text color to primary text color', + (_name, getThemeVariables) => { + const theme = getThemeVariables(); + + expect(theme.xyChart.legendTextColor).toBe(theme.primaryTextColor); + } + ); + + it.each(themes)('%s allows overriding legend text color', (_name, getThemeVariables) => { + const theme = getThemeVariables({ + xyChart: { + legendTextColor: '#123456', + }, + }); + + expect(theme.xyChart.legendTextColor).toBe('#123456'); + }); +}); From 8a9520a230cf9b10558c1e6f1dfe2da0236904d9 Mon Sep 17 00:00:00 2001 From: Xander Dumaine Date: Fri, 22 May 2026 11:46:41 -0400 Subject: [PATCH 4/4] Address XY chart legend review feedback Add visual regression coverage for line and bar legend markers, simplify legend layout helpers, and trim hard-coded test fixtures per reviewer guidance. Co-authored-by: Cursor --- .changeset/xychart-legends.md | 4 +- .../rendering/xychart/xyChart.spec.js | 11 +- .../chartBuilder/components/legend.spec.ts | 96 +++++------ .../xychart/chartBuilder/components/legend.ts | 85 ++++++---- .../xychart/chartBuilder/index.spec.ts | 154 ------------------ .../xychart/chartBuilder/interfaces.ts | 6 + .../mermaid/src/themes/theme-xychart.spec.js | 42 +---- 7 files changed, 111 insertions(+), 287 deletions(-) delete mode 100644 packages/mermaid/src/diagrams/xychart/chartBuilder/index.spec.ts diff --git a/.changeset/xychart-legends.md b/.changeset/xychart-legends.md index 2cbd4e12e0a..df51dd98e68 100644 --- a/.changeset/xychart-legends.md +++ b/.changeset/xychart-legends.md @@ -2,4 +2,6 @@ 'mermaid': minor --- -feat: add legends for named XY chart line and bar series +feat(xyChart): add legends for named line and bar series + +Legends are shown by default for named plots; set `xyChart.showLegend` to `false` to disable them. diff --git a/cypress/integration/rendering/xychart/xyChart.spec.js b/cypress/integration/rendering/xychart/xyChart.spec.js index adb5e2d8bae..4ea22262255 100644 --- a/cypress/integration/rendering/xychart/xyChart.spec.js +++ b/cypress/integration/rendering/xychart/xyChart.spec.js @@ -1,4 +1,4 @@ -import { imgSnapshotTest, renderGraph } from '../../../helpers/util.ts'; +import { imgSnapshotTest } from '../../../helpers/util.ts'; describe('XY Chart', () => { it('should render the simplest possible xy-beta chart', () => { @@ -93,7 +93,7 @@ describe('XY Chart', () => { ); }); it('should render legends for named plots', () => { - renderGraph( + imgSnapshotTest( ` xychart-beta title "An Example Chart" @@ -101,16 +101,17 @@ describe('XY Chart', () => { y-axis "Seconds" 0 --> 198.2 line "avg" [48.1, 41.5, 45.7, 72.8, 67.7, 59.9] line "p50" [38.2, 36.8, 39.7, 54.5, 49.0, 38.4] - line "p95" [112.2, 75.3, 103.0, 177.0, 180.2, 109.4] + bar "p95" [112.2, 75.3, 103.0, 177.0, 180.2, 109.4] `, - { screenshot: false } + {} ); cy.get('g.legend text').should('have.length', 3); cy.get('g.legend').should('contain.text', 'avg'); cy.get('g.legend').should('contain.text', 'p50'); cy.get('g.legend').should('contain.text', 'p95'); - cy.get('g.legend path').should('have.length', 3); + cy.get('g.legend path').should('have.length', 2); + cy.get('g.legend rect').should('have.length', 1); }); it('Decimals and negative numbers are supported', () => { imgSnapshotTest( diff --git a/packages/mermaid/src/diagrams/xychart/chartBuilder/components/legend.spec.ts b/packages/mermaid/src/diagrams/xychart/chartBuilder/components/legend.spec.ts index ae3c126bb26..0ef294edc34 100644 --- a/packages/mermaid/src/diagrams/xychart/chartBuilder/components/legend.spec.ts +++ b/packages/mermaid/src/diagrams/xychart/chartBuilder/components/legend.spec.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; +import defaultConfig from '../../../../defaultConfig.js'; +import themes from '../../../../themes/index.js'; +import type { SVGGroup } from '../../../../diagram-api/types.js'; +import { XYChartBuilder } from '../index.js'; import { ChartLegend } from './legend.js'; +import { getChartLegendComponent } from './legend.js'; import type { XYChartConfig, XYChartData, XYChartThemeConfig } from '../interfaces.js'; import type { TextDimensionCalculator } from '../textDimensionCalculator.js'; @@ -10,62 +15,15 @@ const textDimensionCalculator: TextDimensionCalculator = { }), }; -const chartConfig: XYChartConfig = { - width: 700, - height: 500, - titleFontSize: 20, - titlePadding: 10, - showTitle: true, +const chartConfig = { + ...(defaultConfig.xyChart as XYChartConfig), showLegend: true, - legendFontSize: 14, - legendPadding: 10, - showDataLabel: false, - showDataLabelOutsideBar: false, - chartOrientation: 'vertical', - plotReservedSpacePercent: 50, - xAxis: { - showLabel: true, - labelFontSize: 14, - labelPadding: 5, - showTitle: true, - titleFontSize: 16, - titlePadding: 5, - showTick: true, - tickLength: 5, - tickWidth: 2, - showAxisLine: true, - axisLineWidth: 2, - }, - yAxis: { - showLabel: true, - labelFontSize: 14, - labelPadding: 5, - showTitle: true, - titleFontSize: 16, - titlePadding: 5, - showTick: true, - tickLength: 5, - tickWidth: 2, - showAxisLine: true, - axisLineWidth: 2, - }, -}; +} satisfies XYChartConfig; -const chartThemeConfig: XYChartThemeConfig = { - backgroundColor: '#fff', - titleColor: '#111', - dataLabelColor: '#222', +const chartThemeConfig = { + ...(themes.default.getThemeVariables().xyChart as XYChartThemeConfig), legendTextColor: '#333', - xAxisLabelColor: '#444', - xAxisTitleColor: '#555', - xAxisTickColor: '#666', - xAxisLineColor: '#777', - yAxisLabelColor: '#888', - yAxisTitleColor: '#999', - yAxisTickColor: '#aaa', - yAxisLineColor: '#bbb', - plotColorPalette: '#f00,#0f0', -}; +} satisfies XYChartThemeConfig; const chartData: XYChartData = { title: 'Latency', @@ -210,4 +168,36 @@ describe('ChartLegend', () => { }); expect(crampedLegend.getDrawableElements()).toEqual([]); }); + + it('creates a legend component and integrates with the chart builder', () => { + const legend = getChartLegendComponent( + chartConfig, + chartData, + chartThemeConfig, + undefined as unknown as SVGGroup + ); + + expect(legend.calculateSpace({ width: 200, height: 200 })).toEqual({ + width: 77.4, + height: 55, + }); + + const drawables = XYChartBuilder.build( + { ...chartConfig, chartOrientation: 'horizontal' }, + chartData, + chartThemeConfig, + undefined as unknown as SVGGroup + ); + + expect( + drawables.find( + (drawable) => drawable.type === 'text' && drawable.groupTexts.join('.') === 'legend.label' + )?.data + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ text: 'avg', fill: '#333' }), + expect.objectContaining({ text: 'p95', fill: '#333' }), + ]) + ); + }); }); diff --git a/packages/mermaid/src/diagrams/xychart/chartBuilder/components/legend.ts b/packages/mermaid/src/diagrams/xychart/chartBuilder/components/legend.ts index 6c9c59f8f7f..d774b89c15d 100644 --- a/packages/mermaid/src/diagrams/xychart/chartBuilder/components/legend.ts +++ b/packages/mermaid/src/diagrams/xychart/chartBuilder/components/legend.ts @@ -3,8 +3,10 @@ import type { ChartComponent, Dimension, DrawableElem, + PathElem, PlotData, Point, + RectElem, XYChartConfig, XYChartData, XYChartThemeConfig, @@ -17,6 +19,22 @@ const LEGEND_MARKER_TO_FONT_RATIO = 0.75; const LEGEND_ITEM_SPACING_TO_FONT_RATIO = 0.5; const LEGEND_MARKER_SPACING_TO_FONT_RATIO = 0.35; +interface LegendLayout { + fontSize: number; + markerSize: number; + markerSpacing: number; + itemSpacing: number; +} + +function getLegendLayout(fontSize: number): LegendLayout { + return { + fontSize, + markerSize: fontSize * LEGEND_MARKER_TO_FONT_RATIO, + markerSpacing: fontSize * LEGEND_MARKER_SPACING_TO_FONT_RATIO, + itemSpacing: fontSize * LEGEND_ITEM_SPACING_TO_FONT_RATIO, + }; +} + export class ChartLegend implements ChartComponent { private boundingRect = { x: 0, y: 0, width: 0, height: 0 }; private visiblePlots: PlotData[] = []; @@ -44,10 +62,9 @@ export class ChartLegend implements ChartComponent { return { width: 0, height: 0 }; } - const fontSize = this.chartConfig.legendFontSize; - const markerSize = fontSize * LEGEND_MARKER_TO_FONT_RATIO; - const markerSpacing = fontSize * LEGEND_MARKER_SPACING_TO_FONT_RATIO; - const itemSpacing = fontSize * LEGEND_ITEM_SPACING_TO_FONT_RATIO; + const { fontSize, markerSize, markerSpacing, itemSpacing } = getLegendLayout( + this.chartConfig.legendFontSize + ); const textDimension = this.textDimensionCalculator.getMaxDimension( this.visiblePlots.map((plot) => plot.title), fontSize @@ -80,50 +97,46 @@ export class ChartLegend implements ChartComponent { return []; } - const fontSize = this.chartConfig.legendFontSize; - const markerSize = fontSize * LEGEND_MARKER_TO_FONT_RATIO; - const markerSpacing = fontSize * LEGEND_MARKER_SPACING_TO_FONT_RATIO; - const itemSpacing = fontSize * LEGEND_ITEM_SPACING_TO_FONT_RATIO; + const { fontSize, markerSize, markerSpacing, itemSpacing } = getLegendLayout( + this.chartConfig.legendFontSize + ); const rowHeight = fontSize + itemSpacing; const startX = this.boundingRect.x + this.chartConfig.legendPadding; const startY = this.boundingRect.y + this.chartConfig.legendPadding; + const barMarkers: RectElem[] = []; + const lineMarkers: PathElem[] = []; + + for (const [index, plot] of this.visiblePlots.entries()) { + if (isBarPlot(plot)) { + barMarkers.push({ + x: startX, + y: startY + index * rowHeight, + width: markerSize, + height: markerSize, + fill: plot.fill, + strokeFill: plot.fill, + strokeWidth: 0, + }); + } else { + const markerY = startY + index * rowHeight + markerSize / 2; + lineMarkers.push({ + path: `M ${startX},${markerY} L ${startX + markerSize},${markerY}`, + strokeFill: plot.strokeFill, + strokeWidth: plot.strokeWidth, + }); + } + } return [ { groupTexts: ['legend', 'markers'], type: 'rect', - data: this.visiblePlots.flatMap((plot, index) => - isBarPlot(plot) - ? [ - { - x: startX, - y: startY + index * rowHeight, - width: markerSize, - height: markerSize, - fill: plot.fill, - strokeFill: plot.fill, - strokeWidth: 0, - }, - ] - : [] - ), + data: barMarkers, }, { groupTexts: ['legend', 'markers'], type: 'path', - data: this.visiblePlots.flatMap((plot, index) => { - if (isBarPlot(plot)) { - return []; - } - const markerY = startY + index * rowHeight + markerSize / 2; - return [ - { - path: `M ${startX},${markerY} L ${startX + markerSize},${markerY}`, - strokeFill: plot.strokeFill, - strokeWidth: plot.strokeWidth, - }, - ]; - }), + data: lineMarkers, }, { groupTexts: ['legend', 'label'], diff --git a/packages/mermaid/src/diagrams/xychart/chartBuilder/index.spec.ts b/packages/mermaid/src/diagrams/xychart/chartBuilder/index.spec.ts deleted file mode 100644 index 9357af9f4eb..00000000000 --- a/packages/mermaid/src/diagrams/xychart/chartBuilder/index.spec.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { XYChartBuilder } from './index.js'; -import type { SVGGroup } from '../../../diagram-api/types.js'; -import type { XYChartConfig, XYChartData, XYChartThemeConfig } from './interfaces.js'; - -const chartConfig: XYChartConfig = { - width: 700, - height: 500, - titleFontSize: 20, - titlePadding: 10, - showTitle: true, - showLegend: true, - legendFontSize: 14, - legendPadding: 10, - showDataLabel: false, - showDataLabelOutsideBar: false, - chartOrientation: 'vertical', - plotReservedSpacePercent: 50, - xAxis: { - showLabel: true, - labelFontSize: 14, - labelPadding: 5, - showTitle: true, - titleFontSize: 16, - titlePadding: 5, - showTick: true, - tickLength: 5, - tickWidth: 2, - showAxisLine: true, - axisLineWidth: 2, - }, - yAxis: { - showLabel: true, - labelFontSize: 14, - labelPadding: 5, - showTitle: true, - titleFontSize: 16, - titlePadding: 5, - showTick: true, - tickLength: 5, - tickWidth: 2, - showAxisLine: true, - axisLineWidth: 2, - }, -}; - -const chartThemeConfig: XYChartThemeConfig = { - backgroundColor: '#fff', - titleColor: '#111', - dataLabelColor: '#222', - legendTextColor: '#333', - xAxisLabelColor: '#444', - xAxisTitleColor: '#555', - xAxisTickColor: '#666', - xAxisLineColor: '#777', - yAxisLabelColor: '#888', - yAxisTitleColor: '#999', - yAxisTickColor: '#aaa', - yAxisLineColor: '#bbb', - plotColorPalette: '#f00,#0f0', -}; - -const chartData: XYChartData = { - title: 'An Example Chart', - xAxis: { - type: 'band', - title: '', - categories: ['90d', '60d', '30d'], - }, - yAxis: { - type: 'linear', - title: 'Seconds', - min: 0, - max: 200, - }, - plots: [ - { - type: 'line', - title: 'avg', - strokeFill: '#f00', - strokeWidth: 2, - data: [ - ['90d', 48.1], - ['60d', 41.5], - ['30d', 45.7], - ], - }, - { - type: 'bar', - title: 'p95', - fill: '#0f0', - data: [ - ['90d', 112.2], - ['60d', 75.3], - ['30d', 103.0], - ], - }, - ], -}; - -describe('XYChartBuilder', () => { - it('includes legend drawables when named plots are rendered', () => { - const drawables = XYChartBuilder.build( - chartConfig, - chartData, - chartThemeConfig, - undefined as unknown as SVGGroup - ); - - const legendLabels = drawables.find( - (drawable) => drawable.type === 'text' && drawable.groupTexts.join('.') === 'legend.label' - ); - const legendLineMarkers = drawables.find( - (drawable) => drawable.type === 'path' && drawable.groupTexts.join('.') === 'legend.markers' - ); - const legendBarMarkers = drawables.find( - (drawable) => drawable.type === 'rect' && drawable.groupTexts.join('.') === 'legend.markers' - ); - - expect(legendLabels?.data).toEqual( - expect.arrayContaining([ - expect.objectContaining({ text: 'avg', fill: '#333' }), - expect.objectContaining({ text: 'p95', fill: '#333' }), - ]) - ); - expect(legendLineMarkers?.data).toEqual([ - expect.objectContaining({ strokeFill: '#f00', strokeWidth: 2 }), - ]); - expect(legendBarMarkers?.data).toEqual([ - expect.objectContaining({ fill: '#0f0', strokeFill: '#0f0' }), - ]); - }); - - it('positions horizontal chart legends beside the plot', () => { - const drawables = XYChartBuilder.build( - { ...chartConfig, chartOrientation: 'horizontal' }, - chartData, - chartThemeConfig, - undefined as unknown as SVGGroup - ); - - const legendLabels = drawables.find( - (drawable) => drawable.type === 'text' && drawable.groupTexts.join('.') === 'legend.label' - ); - - expect(legendLabels?.data[0]).toEqual( - expect.objectContaining({ - text: 'avg', - horizontalPos: 'left', - verticalPos: 'middle', - }) - ); - }); -}); diff --git a/packages/mermaid/src/diagrams/xychart/chartBuilder/interfaces.ts b/packages/mermaid/src/diagrams/xychart/chartBuilder/interfaces.ts index 661d9178fc2..c1ca51b2252 100644 --- a/packages/mermaid/src/diagrams/xychart/chartBuilder/interfaces.ts +++ b/packages/mermaid/src/diagrams/xychart/chartBuilder/interfaces.ts @@ -31,6 +31,9 @@ export type SimplePlotDataType = [string, number][]; export interface LinePlotData { type: 'line'; + /** + * The title of this plot, or `""` if there is no title. + */ title: string; strokeFill: string; strokeWidth: number; @@ -39,6 +42,9 @@ export interface LinePlotData { export interface BarPlotData { type: 'bar'; + /** + * The title of this plot, or `""` if there is no title. + */ title: string; fill: string; data: SimplePlotDataType; diff --git a/packages/mermaid/src/themes/theme-xychart.spec.js b/packages/mermaid/src/themes/theme-xychart.spec.js index f2113eb05da..567d64fa276 100644 --- a/packages/mermaid/src/themes/theme-xychart.spec.js +++ b/packages/mermaid/src/themes/theme-xychart.spec.js @@ -1,46 +1,12 @@ -import { getThemeVariables as getBaseThemeVariables } from './theme-base.js'; -import { getThemeVariables as getDarkThemeVariables } from './theme-dark.js'; -import { getThemeVariables as getDefaultThemeVariables } from './theme-default.js'; -import { getThemeVariables as getForestThemeVariables } from './theme-forest.js'; -import { getThemeVariables as getNeoDarkThemeVariables } from './theme-neo-dark.js'; -import { getThemeVariables as getNeoThemeVariables } from './theme-neo.js'; -import { getThemeVariables as getNeutralThemeVariables } from './theme-neutral.js'; -import { getThemeVariables as getReduxColorThemeVariables } from './theme-redux-color.js'; -import { getThemeVariables as getReduxDarkColorThemeVariables } from './theme-redux-dark-color.js'; -import { getThemeVariables as getReduxDarkThemeVariables } from './theme-redux-dark.js'; -import { getThemeVariables as getReduxThemeVariables } from './theme-redux.js'; - -const themes = [ - ['base', getBaseThemeVariables], - ['dark', getDarkThemeVariables], - ['default', getDefaultThemeVariables], - ['forest', getForestThemeVariables], - ['neo-dark', getNeoDarkThemeVariables], - ['neo', getNeoThemeVariables], - ['neutral', getNeutralThemeVariables], - ['redux-color', getReduxColorThemeVariables], - ['redux-dark-color', getReduxDarkColorThemeVariables], - ['redux-dark', getReduxDarkThemeVariables], - ['redux', getReduxThemeVariables], -]; +import themes from './index.js'; describe('xychart theme variables', () => { - it.each(themes)( + it.each(Object.entries(themes))( '%s defaults legend text color to primary text color', - (_name, getThemeVariables) => { - const theme = getThemeVariables(); + (_name, themeProvider) => { + const theme = themeProvider.getThemeVariables(); expect(theme.xyChart.legendTextColor).toBe(theme.primaryTextColor); } ); - - it.each(themes)('%s allows overriding legend text color', (_name, getThemeVariables) => { - const theme = getThemeVariables({ - xyChart: { - legendTextColor: '#123456', - }, - }); - - expect(theme.xyChart.legendTextColor).toBe('#123456'); - }); });