diff --git a/.changeset/xychart-legends.md b/.changeset/xychart-legends.md new file mode 100644 index 00000000000..df51dd98e68 --- /dev/null +++ b/.changeset/xychart-legends.md @@ -0,0 +1,7 @@ +--- +'mermaid': minor +--- + +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 f96acbfca7a..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', () => { @@ -92,6 +92,27 @@ describe('XY Chart', () => { {} ); }); + it('should render legends for named plots', () => { + imgSnapshotTest( + ` + 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] + bar "p95" [112.2, 75.3, 103.0, 177.0, 180.2, 109.4] + `, + {} + ); + + 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', 2); + cy.get('g.legend rect').should('have.length', 1); + }); it('Decimals and negative numbers are supported', () => { imgSnapshotTest( ` diff --git a/docs/syntax/xyChart.md b/docs/syntax/xyChart.md index 28712b1c0da..4a014de0905 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 (v\+) + +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.spec.ts b/packages/mermaid/src/diagrams/xychart/chartBuilder/components/legend.spec.ts new file mode 100644 index 00000000000..0ef294edc34 --- /dev/null +++ b/packages/mermaid/src/diagrams/xychart/chartBuilder/components/legend.spec.ts @@ -0,0 +1,203 @@ +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'; + +const textDimensionCalculator: TextDimensionCalculator = { + getMaxDimension: (texts, fontSize) => ({ + width: Math.max(...texts.map((text) => text.length)) * fontSize, + height: fontSize, + }), +}; + +const chartConfig = { + ...(defaultConfig.xyChart as XYChartConfig), + showLegend: true, +} satisfies XYChartConfig; + +const chartThemeConfig = { + ...(themes.default.getThemeVariables().xyChart as XYChartThemeConfig), + legendTextColor: '#333', +} satisfies XYChartThemeConfig; + +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([]); + }); + + 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 new file mode 100644 index 00000000000..d774b89c15d --- /dev/null +++ b/packages/mermaid/src/diagrams/xychart/chartBuilder/components/legend.ts @@ -0,0 +1,167 @@ +import type { SVGGroup } from '../../../../diagram-api/types.js'; +import type { + ChartComponent, + Dimension, + DrawableElem, + PathElem, + PlotData, + Point, + RectElem, + 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; + +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[] = []; + + 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, markerSize, markerSpacing, itemSpacing } = getLegendLayout( + this.chartConfig.legendFontSize + ); + 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, 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: barMarkers, + }, + { + groupTexts: ['legend', 'markers'], + type: 'path', + data: lineMarkers, + }, + { + 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..c1ca51b2252 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,10 @@ 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; data: SimplePlotDataType; @@ -37,6 +42,10 @@ 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; } @@ -94,6 +103,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.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/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..fe9d120d61b 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 (v+) + +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, 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..567d64fa276 --- /dev/null +++ b/packages/mermaid/src/themes/theme-xychart.spec.js @@ -0,0 +1,12 @@ +import themes from './index.js'; + +describe('xychart theme variables', () => { + it.each(Object.entries(themes))( + '%s defaults legend text color to primary text color', + (_name, themeProvider) => { + const theme = themeProvider.getThemeVariables(); + + expect(theme.xyChart.legendTextColor).toBe(theme.primaryTextColor); + } + ); +});