From d80cc38bb24a62bb2f2392931963a7db1f3fcd9e Mon Sep 17 00:00:00 2001 From: Thomas Di Cizerone Date: Sun, 16 Mar 2025 18:00:50 +0100 Subject: [PATCH 01/17] =?UTF-8?q?=F0=9F=96=8B=EF=B8=8F=20Add=20grammar=20f?= =?UTF-8?q?or=20Radar=20chart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .build/jsonSchema.ts | 1 + packages/parser/langium-config.json | 5 + packages/parser/src/language/index.ts | 3 + packages/parser/src/language/radar/index.ts | 1 + packages/parser/src/language/radar/module.ts | 73 ++++ .../parser/src/language/radar/radar.langium | 89 +++++ .../parser/src/language/radar/tokenBuilder.ts | 7 + packages/parser/src/parse.ts | 10 +- packages/parser/tests/packet.test.ts | 19 + packages/parser/tests/radar.test.ts | 343 ++++++++++++++++++ packages/parser/tests/test-util.ts | 28 ++ 11 files changed, 577 insertions(+), 2 deletions(-) create mode 100644 packages/parser/src/language/radar/index.ts create mode 100644 packages/parser/src/language/radar/module.ts create mode 100644 packages/parser/src/language/radar/radar.langium create mode 100644 packages/parser/src/language/radar/tokenBuilder.ts create mode 100644 packages/parser/tests/packet.test.ts create mode 100644 packages/parser/tests/radar.test.ts diff --git a/.build/jsonSchema.ts b/.build/jsonSchema.ts index 7a700c1e2..48a9883de 100644 --- a/.build/jsonSchema.ts +++ b/.build/jsonSchema.ts @@ -27,6 +27,7 @@ const MERMAID_CONFIG_DIAGRAM_KEYS = [ 'block', 'packet', 'architecture', + 'radar', ] as const; /** diff --git a/packages/parser/langium-config.json b/packages/parser/langium-config.json index bf64493ad..ad80350c2 100644 --- a/packages/parser/langium-config.json +++ b/packages/parser/langium-config.json @@ -25,6 +25,11 @@ "id": "gitGraph", "grammar": "src/language/gitGraph/gitGraph.langium", "fileExtensions": [".mmd", ".mermaid"] + }, + { + "id": "radar", + "grammar": "src/language/radar/radar.langium", + "fileExtensions": [".mmd", ".mermaid"] } ], "mode": "production", diff --git a/packages/parser/src/language/index.ts b/packages/parser/src/language/index.ts index c85a5a8b6..e3aa5c68c 100644 --- a/packages/parser/src/language/index.ts +++ b/packages/parser/src/language/index.ts @@ -7,6 +7,7 @@ export { PieSection, Architecture, GitGraph, + Radar, Branch, Commit, Merge, @@ -31,6 +32,7 @@ export { PieGeneratedModule, ArchitectureGeneratedModule, GitGraphGeneratedModule, + RadarGeneratedModule, } from './generated/module.js'; export * from './gitGraph/index.js'; @@ -39,3 +41,4 @@ export * from './info/index.js'; export * from './packet/index.js'; export * from './pie/index.js'; export * from './architecture/index.js'; +export * from './radar/index.js'; diff --git a/packages/parser/src/language/radar/index.ts b/packages/parser/src/language/radar/index.ts new file mode 100644 index 000000000..fd3c604b0 --- /dev/null +++ b/packages/parser/src/language/radar/index.ts @@ -0,0 +1 @@ +export * from './module.js'; diff --git a/packages/parser/src/language/radar/module.ts b/packages/parser/src/language/radar/module.ts new file mode 100644 index 000000000..de604724a --- /dev/null +++ b/packages/parser/src/language/radar/module.ts @@ -0,0 +1,73 @@ +import type { + DefaultSharedCoreModuleContext, + LangiumCoreServices, + LangiumSharedCoreServices, + Module, + PartialLangiumCoreServices, +} from 'langium'; +import { + EmptyFileSystem, + createDefaultCoreModule, + createDefaultSharedCoreModule, + inject, +} from 'langium'; +import { CommonValueConverter } from '../common/valueConverter.js'; +import { MermaidGeneratedSharedModule, RadarGeneratedModule } from '../generated/module.js'; +import { RadarTokenBuilder } from './tokenBuilder.js'; + +/** + * Declaration of `Radar` services. + */ +interface RadarAddedServices { + parser: { + TokenBuilder: RadarTokenBuilder; + ValueConverter: CommonValueConverter; + }; +} + +/** + * Union of Langium default services and `Radar` services. + */ +export type RadarServices = LangiumCoreServices & RadarAddedServices; + +/** + * Dependency injection module that overrides Langium default services and + * contributes the declared `Radar` services. + */ +export const RadarModule: Module = { + parser: { + TokenBuilder: () => new RadarTokenBuilder(), + ValueConverter: () => new CommonValueConverter(), + }, +}; + +/** + * Create the full set of services required by Langium. + * + * First inject the shared services by merging two modules: + * - Langium default shared services + * - Services generated by langium-cli + * + * Then inject the language-specific services by merging three modules: + * - Langium default language-specific services + * - Services generated by langium-cli + * - Services specified in this file + * @param context - Optional module context with the LSP connection + * @returns An object wrapping the shared services and the language-specific services + */ +export function createRadarServices(context: DefaultSharedCoreModuleContext = EmptyFileSystem): { + shared: LangiumSharedCoreServices; + Radar: RadarServices; +} { + const shared: LangiumSharedCoreServices = inject( + createDefaultSharedCoreModule(context), + MermaidGeneratedSharedModule + ); + const Radar: RadarServices = inject( + createDefaultCoreModule({ shared }), + RadarGeneratedModule, + RadarModule + ); + shared.ServiceRegistry.register(Radar); + return { shared, Radar }; +} diff --git a/packages/parser/src/language/radar/radar.langium b/packages/parser/src/language/radar/radar.langium new file mode 100644 index 000000000..f0ecd13cd --- /dev/null +++ b/packages/parser/src/language/radar/radar.langium @@ -0,0 +1,89 @@ +grammar Radar +// import "../common/common"; +// Note: The import statement breaks TitleAndAccessibilities probably because of terminal order definition +// TODO: May need to change the common.langium to fix this + +interface Common { + accDescr?: string; + accTitle?: string; + title?: string; +} + +fragment TitleAndAccessibilities: + ((accDescr=ACC_DESCR | accTitle=ACC_TITLE | title=TITLE) EOL)+ +; + +fragment EOL returns string: + NEWLINE+ | EOF +; + +terminal NEWLINE: /\r?\n/; +terminal ACC_DESCR: /[\t ]*accDescr(?:[\t ]*:([^\n\r]*?(?=%%)|[^\n\r]*)|\s*{([^}]*)})/; +terminal ACC_TITLE: /[\t ]*accTitle[\t ]*:(?:[^\n\r]*?(?=%%)|[^\n\r]*)/; +terminal TITLE: /[\t ]*title(?:[\t ][^\n\r]*?(?=%%)|[\t ][^\n\r]*|)/; + +hidden terminal WHITESPACE: /[\t ]+/; +hidden terminal YAML: /---[\t ]*\r?\n(?:[\S\s]*?\r?\n)?---(?:\r?\n|(?!\S))/; +hidden terminal DIRECTIVE: /[\t ]*%%{[\S\s]*?}%%(?:\r?\n|(?!\S))/; +hidden terminal SINGLE_LINE_COMMENT: /[\t ]*%%[^\n\r]*/; + +entry Radar: + NEWLINE* + ('radar-beta' | 'radar-beta:' | 'radar-beta' ':') + NEWLINE* + ( + TitleAndAccessibilities + | 'axis' axes+=Axis (',' axes+=Axis)* + | 'curve' curves+=Curve (',' curves+=Curve)* + | options+=Option (',' options+=Option)* + | NEWLINE + )* +; + +fragment Label: + '[' label=STRING ']' +; + +Axis: + name=ID (Label)? +; + +Curve: + name=ID (Label)? '{' Entries '}' +; + +fragment Entries: + NEWLINE* entries+=NumberEntry (',' NEWLINE* entries+=NumberEntry)* NEWLINE* | + NEWLINE* entries+=DetailedEntry (',' NEWLINE* entries+=DetailedEntry)* NEWLINE* +; + +interface Entry { + axis?: @Axis; + value: number; +} +DetailedEntry returns Entry: + axis=[Axis:ID] ':'? value=NUMBER +; +NumberEntry returns Entry: + value=NUMBER +; + +Option: + ( + name='showLegend' value=BOOLEAN + | name='ticks' value=NUMBER + | name='max' value=NUMBER + | name='min' value=NUMBER + | name='graticule' value=GRATICULE + ) +; + + +terminal NUMBER returns number: /(0|[1-9][0-9]*)(\.[0-9]+)?/; + +terminal BOOLEAN returns boolean: 'true' | 'false'; + +terminal GRATICULE returns string: 'circle' | 'polygon'; + +terminal ID returns string: /[a-zA-Z_][a-zA-Z0-9\-_]*/; +terminal STRING: /"[^"]*"|'[^']*'/; \ No newline at end of file diff --git a/packages/parser/src/language/radar/tokenBuilder.ts b/packages/parser/src/language/radar/tokenBuilder.ts new file mode 100644 index 000000000..b016fb5d8 --- /dev/null +++ b/packages/parser/src/language/radar/tokenBuilder.ts @@ -0,0 +1,7 @@ +import { AbstractMermaidTokenBuilder } from '../common/index.js'; + +export class RadarTokenBuilder extends AbstractMermaidTokenBuilder { + public constructor() { + super(['radar-beta']); + } +} diff --git a/packages/parser/src/parse.ts b/packages/parser/src/parse.ts index 86713c2f1..020a86f7b 100644 --- a/packages/parser/src/parse.ts +++ b/packages/parser/src/parse.ts @@ -1,8 +1,8 @@ import type { LangiumParser, ParseResult } from 'langium'; -import type { Info, Packet, Pie, Architecture, GitGraph } from './index.js'; +import type { Info, Packet, Pie, Architecture, GitGraph, Radar } from './index.js'; -export type DiagramAST = Info | Packet | Pie | Architecture | GitGraph; +export type DiagramAST = Info | Packet | Pie | Architecture | GitGraph | Radar; const parsers: Record = {}; const initializers = { @@ -31,6 +31,11 @@ const initializers = { const parser = createGitGraphServices().GitGraph.parser.LangiumParser; parsers.gitGraph = parser; }, + radar: async () => { + const { createRadarServices } = await import('./language/radar/index.js'); + const parser = createRadarServices().Radar.parser.LangiumParser; + parsers.radar = parser; + }, } as const; export async function parse(diagramType: 'info', text: string): Promise; @@ -38,6 +43,7 @@ export async function parse(diagramType: 'packet', text: string): Promise; export async function parse(diagramType: 'architecture', text: string): Promise; export async function parse(diagramType: 'gitGraph', text: string): Promise; +export async function parse(diagramType: 'radar', text: string): Promise; export async function parse( diagramType: keyof typeof initializers, diff --git a/packages/parser/tests/packet.test.ts b/packages/parser/tests/packet.test.ts new file mode 100644 index 000000000..eb2ea303d --- /dev/null +++ b/packages/parser/tests/packet.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { Packet } from '../src/language/index.js'; +import { expectNoErrorsOrAlternatives, packetParse as parse } from './test-util.js'; + +describe('packet', () => { + it.each([ + `packet-beta`, + ` packet-beta `, + `\tpacket-beta\t`, + ` + \tpacket-beta + `, + ])('should handle regular packet', (context: string) => { + const result = parse(context); + expectNoErrorsOrAlternatives(result); + expect(result.value.$type).toBe(Packet); + }); +}); diff --git a/packages/parser/tests/radar.test.ts b/packages/parser/tests/radar.test.ts new file mode 100644 index 000000000..5d483d53c --- /dev/null +++ b/packages/parser/tests/radar.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, it } from 'vitest'; + +import { Radar } from '../src/language/index.js'; +import { expectNoErrorsOrAlternatives, radarParse as parse } from './test-util.js'; + +const mutateGlobalSpacing = (context: string) => { + return [ + context, + ` ${context} `, + `\t${context}\t`, + ` + \t${context} + `, + ]; +}; + +describe('radar', () => { + it.each([ + ...mutateGlobalSpacing('radar-beta'), + ...mutateGlobalSpacing('radar-beta:'), + ...mutateGlobalSpacing('radar-beta :'), + ])('should handle regular radar', (context: string) => { + const result = parse(context); + expectNoErrorsOrAlternatives(result); + expect(result.value.$type).toBe(Radar); + }); + + describe('should handle title, accDescr, and accTitle', () => { + it.each([ + ...mutateGlobalSpacing(' title My Title'), + ...mutateGlobalSpacing('\n title My Title'), + ])('should handle title', (context: string) => { + const result = parse(`radar-beta${context}`); + expectNoErrorsOrAlternatives(result); + expect(result.value.$type).toBe(Radar); + + const { title } = result.value; + expect(title).toBe('My Title'); + }); + + it.each([ + ...mutateGlobalSpacing(' accDescr: My Accessible Description'), + ...mutateGlobalSpacing('\n accDescr: My Accessible Description'), + ])('should handle accDescr', (context: string) => { + const result = parse(`radar-beta${context}`); + expectNoErrorsOrAlternatives(result); + expect(result.value.$type).toBe(Radar); + + const { accDescr } = result.value; + expect(accDescr).toBe('My Accessible Description'); + }); + + it.each([ + ...mutateGlobalSpacing(' accTitle: My Accessible Title'), + ...mutateGlobalSpacing('\n accTitle: My Accessible Title'), + ])('should handle accTitle', (context: string) => { + const result = parse(`radar-beta${context}`); + expectNoErrorsOrAlternatives(result); + expect(result.value.$type).toBe(Radar); + + const { accTitle } = result.value; + expect(accTitle).toBe('My Accessible Title'); + }); + + it.each([ + ...mutateGlobalSpacing( + ' title My Title\n accDescr: My Accessible Description\n accTitle: My Accessible Title' + ), + ...mutateGlobalSpacing( + '\n title My Title\n accDescr: My Accessible Description\n accTitle: My Accessible Title' + ), + ])('should handle title + accDescr + accTitle', (context: string) => { + const result = parse(`radar-beta${context}`); + expectNoErrorsOrAlternatives(result); + expect(result.value.$type).toBe(Radar); + + const { title, accDescr, accTitle } = result.value; + expect(title).toBe('My Title'); + expect(accDescr).toBe('My Accessible Description'); + expect(accTitle).toBe('My Accessible Title'); + }); + }); + + describe('should handle axis', () => { + it.each([`axis my-axis`, `axis my-axis["My Axis Label"]`])( + 'should handle one axis', + (context: string) => { + const result = parse(`radar-beta\n${context}`); + expectNoErrorsOrAlternatives(result); + expect(result.value.$type).toBe(Radar); + + const { axes } = result.value; + expect(axes).toHaveLength(1); + expect(axes[0].$type).toBe('Axis'); + expect(axes[0].name).toBe('my-axis'); + } + ); + + it.each([ + `axis my-axis["My Axis Label"] + axis my-axis2`, + `axis my-axis, my-axis2`, + `axis my-axis["My Axis Label"], my-axis2`, + `axis my-axis, my-axis2["My Second Axis Label"]`, + ])('should handle multiple axes', (context: string) => { + const result = parse(`radar-beta\n${context}`); + expectNoErrorsOrAlternatives(result); + expect(result.value.$type).toBe(Radar); + + const { axes } = result.value; + expect(axes).toHaveLength(2); + expect(axes.every((axis) => axis.$type === 'Axis')).toBe(true); + expect(axes[0].name).toBe('my-axis'); + expect(axes[1].name).toBe('my-axis2'); + }); + + it.each([ + `axis my-axis["My Axis Label"] + axis my-axis2["My Second Axis Label"]`, + `axis my-axis ["My Axis Label"], my-axis2\t["My Second Axis Label"]`, + ])('should handle axis labels', (context: string) => { + const result = parse(`radar-beta\n${context}`); + expectNoErrorsOrAlternatives(result); + expect(result.value.$type).toBe(Radar); + + const { axes } = result.value; + expect(axes).toHaveLength(2); + expect(axes[0].name).toBe('my-axis'); + expect(axes[0].label).toBe('My Axis Label'); + expect(axes[1].name).toBe('my-axis2'); + expect(axes[1].label).toBe('My Second Axis Label'); + }); + + it('should not allow empty axis names', () => { + const result = parse(`radar-beta + axis`); + expect(result.parserErrors).not.toHaveLength(0); + }); + + it('should not allow non-comma separated axis names', () => { + const result = parse(`radar-beta + axis my-axis my-axis2`); + expect(result.parserErrors).not.toHaveLength(0); + }); + }); + + describe('should handle curves', () => { + it.each([ + `radar-beta + curve my-curve`, + `radar-beta + curve my-curve["My Curve Label"]`, + ])('should not allow curves without axes', (context: string) => { + const result = parse(`radar-beta${context}`); + expect(result.parserErrors).not.toHaveLength(0); + }); + + it.each([ + `radar-beta + axis my-axis + curve my-curve`, + `radar-beta + axis my-axis + curve my-curve["My Curve Label"]`, + ])('should not allow curves without entries', (context: string) => { + const result = parse(`radar-beta${context}`); + expect(result.parserErrors).not.toHaveLength(0); + }); + + it.each([ + `curve my-curve { 1 }`, + `curve my-curve { + 1 + }`, + `curve my-curve { + + 1 + + }`, + ])('should handle one curve with one entry', (context: string) => { + const result = parse(`radar-beta\naxis my-axis\n${context}`); + expectNoErrorsOrAlternatives(result); + expect(result.value.$type).toBe(Radar); + + const { curves } = result.value; + expect(curves).toHaveLength(1); + expect(curves[0].$type).toBe('Curve'); + expect(curves[0].name).toBe('my-curve'); + expect(curves[0].entries).toHaveLength(1); + expect(curves[0].entries[0].$type).toBe('Entry'); + expect(curves[0].entries[0].value).toBe(1); + }); + + it.each([ + `curve my-curve { my-axis 1 }`, + `curve my-curve { my-axis : 1 }`, + `curve my-curve { + my-axis: 1 + }`, + ])('should handle one curve with one detailed entry', (context: string) => { + const result = parse(`radar-beta\naxis my-axis\n${context}`); + expectNoErrorsOrAlternatives(result); + expect(result.value.$type).toBe(Radar); + + const { curves } = result.value; + expect(curves).toHaveLength(1); + expect(curves[0].$type).toBe('Curve'); + expect(curves[0].name).toBe('my-curve'); + expect(curves[0].entries).toHaveLength(1); + expect(curves[0].entries[0].$type).toBe('Entry'); + expect(curves[0].entries[0].value).toBe(1); + expect(curves[0].entries[0]?.axis?.$refText).toBe('my-axis'); + }); + + it.each([ + `curve my-curve { ax1 1, ax2 2 }`, + `curve my-curve { + ax1 1, + ax2 2 + }`, + `curve my-curve["My Curve Label"] { + ax1: 1, ax2: 2 + }`, + ])('should handle one curve with multiple detailed entries', (context: string) => { + const result = parse(`radar-beta\naxis ax1, ax1\n${context}`); + expectNoErrorsOrAlternatives(result); + expect(result.value.$type).toBe(Radar); + + const { curves } = result.value; + expect(curves).toHaveLength(1); + expect(curves[0].$type).toBe('Curve'); + expect(curves[0].name).toBe('my-curve'); + expect(curves[0].entries).toHaveLength(2); + expect(curves[0].entries[0].$type).toBe('Entry'); + expect(curves[0].entries[0].value).toBe(1); + expect(curves[0].entries[0]?.axis?.$refText).toBe('ax1'); + expect(curves[0].entries[1].$type).toBe('Entry'); + expect(curves[0].entries[1].value).toBe(2); + expect(curves[0].entries[1]?.axis?.$refText).toBe('ax2'); + }); + + it.each([ + `curve c1 { ax1 1, ax2 2 } + curve c2 { ax1 3, ax2 4 }`, + `curve c1 { + ax1 1, + ax2 2 + } + curve c2 { + ax1 3, + ax2 4 + }`, + `curve c1{ 1, 2 }, c2{ 3, 4 }`, + ])('should handle multiple curves', (context: string) => { + const result = parse(`radar-beta\naxis ax1, ax1\n${context}`); + expectNoErrorsOrAlternatives(result); + expect(result.value.$type).toBe(Radar); + + const { curves } = result.value; + expect(curves).toHaveLength(2); + expect(curves.every((curve) => curve.$type === 'Curve')).toBe(true); + expect(curves[0].name).toBe('c1'); + expect(curves[1].name).toBe('c2'); + }); + + it('should not allow empty curve names', () => { + const result = parse(`radar-beta + axis my-axis + curve`); + expect(result.parserErrors).not.toHaveLength(0); + }); + + it('should not allow number and detailed entries in the same curve', () => { + const result = parse(`radar-beta + axis ax1, ax2 + curve my-curve { 1, ax1 2 }`); + expect(result.parserErrors).not.toHaveLength(0); + }); + + it('should not allow non-comma separated entries', () => { + const result = parse(`radar-beta + axis ax1, ax2 + curve my-curve { ax1 1 ax2 2 }`); + expect(result.parserErrors).not.toHaveLength(0); + }); + }); + + describe('should handle options', () => { + it.each([`ticks 5`, `min 50`, `max 50`])( + `should handle number option %s`, + (context: string) => { + const result = parse(`radar-beta + axis ax1, ax2 + curve c1 { ax1 1, ax2 2 } + ${context}`); + expectNoErrorsOrAlternatives(result); + expect(result.value.$type).toBe(Radar); + + const { options } = result.value; + expect(options).toBeDefined(); + const option = options.find((option) => option.name === context.split(' ')[0]); + expect(option).toBeDefined(); + expect(option?.value).toBe(Number(context.split(' ')[1])); + } + ); + + it.each([`graticule circle`, `graticule polygon`])( + `should handle string option %s`, + (context: string) => { + const result = parse(`radar-beta + axis ax1, ax2 + curve c1 { ax1 1, ax2 2 } + ${context}`); + expectNoErrorsOrAlternatives(result); + expect(result.value.$type).toBe(Radar); + + const { options } = result.value; + expect(options).toBeDefined(); + const option = options.find((option) => option.name === context.split(' ')[0]); + expect(option).toBeDefined(); + expect(option?.value).toBe(context.split(' ')[1]); + } + ); + + it.each([`showLegend true`, `showLegend false`])( + `should handle boolean option %s`, + (context: string) => { + const result = parse(`radar-beta + axis ax1, ax2 + curve c1 { ax1 1, ax2 2 } + ${context}`); + expectNoErrorsOrAlternatives(result); + expect(result.value.$type).toBe(Radar); + + const { options } = result.value; + expect(options).toBeDefined(); + const option = options.find((option) => option.name === context.split(' ')[0]); + expect(option).toBeDefined(); + expect(option?.value).toBe(context.split(' ')[1] === 'true'); + } + ); + }); +}); diff --git a/packages/parser/tests/test-util.ts b/packages/parser/tests/test-util.ts index 5cb487758..bc2224e65 100644 --- a/packages/parser/tests/test-util.ts +++ b/packages/parser/tests/test-util.ts @@ -5,12 +5,18 @@ import type { InfoServices, Pie, PieServices, + Radar, + RadarServices, + Packet, + PacketServices, GitGraph, GitGraphServices, } from '../src/language/index.js'; import { createInfoServices, createPieServices, + createRadarServices, + createPacketServices, createGitGraphServices, } from '../src/language/index.js'; @@ -52,6 +58,28 @@ export function createPieTestServices() { } export const pieParse = createPieTestServices().parse; +const packetServices: PacketServices = createPacketServices().Packet; +const packetParser: LangiumParser = packetServices.parser.LangiumParser; +export function createPacketTestServices() { + const parse = (input: string) => { + return packetParser.parse(input); + }; + + return { services: packetServices, parse }; +} +export const packetParse = createPacketTestServices().parse; + +const radarServices: RadarServices = createRadarServices().Radar; +const radarParser: LangiumParser = radarServices.parser.LangiumParser; +export function createRadarTestServices() { + const parse = (input: string) => { + return radarParser.parse(input); + }; + + return { services: radarServices, parse }; +} +export const radarParse = createRadarTestServices().parse; + const gitGraphServices: GitGraphServices = createGitGraphServices().GitGraph; const gitGraphParser: LangiumParser = gitGraphServices.parser.LangiumParser; export function createGitGraphTestServices() { From 1fb91d14c96fca6f0a10cfc90ea0b8bcba6a47c8 Mon Sep 17 00:00:00 2001 From: Thomas Di Cizerone Date: Sun, 16 Mar 2025 18:13:50 +0100 Subject: [PATCH 02/17] =?UTF-8?q?=F0=9F=93=8A=20Add=20radar=20chart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/community/contributing.md | 16 ++ .../defaultConfig/variables/configKeys.md | 2 +- .../setup/mermaid/interfaces/MermaidConfig.md | 18 +- docs/syntax/radar.md | 253 ++++++++++++++++++ packages/mermaid/src/config.type.ts | 45 ++++ packages/mermaid/src/defaultConfig.ts | 4 + .../src/diagram-api/diagram-orchestration.ts | 4 +- packages/mermaid/src/diagrams/radar/db.ts | 128 +++++++++ .../mermaid/src/diagrams/radar/detector.ts | 22 ++ .../mermaid/src/diagrams/radar/diagram.ts | 12 + packages/mermaid/src/diagrams/radar/parser.ts | 23 ++ .../mermaid/src/diagrams/radar/radar.spec.ts | 167 ++++++++++++ .../mermaid/src/diagrams/radar/renderer.ts | 226 ++++++++++++++++ packages/mermaid/src/diagrams/radar/styles.ts | 71 +++++ packages/mermaid/src/diagrams/radar/types.ts | 47 ++++ packages/mermaid/src/mermaidAPI.spec.ts | 2 + .../mermaid/src/schemas/config.schema.yaml | 57 ++++ packages/mermaid/src/styles.spec.ts | 2 + 18 files changed, 1092 insertions(+), 7 deletions(-) create mode 100644 docs/syntax/radar.md create mode 100644 packages/mermaid/src/diagrams/radar/db.ts create mode 100644 packages/mermaid/src/diagrams/radar/detector.ts create mode 100644 packages/mermaid/src/diagrams/radar/diagram.ts create mode 100644 packages/mermaid/src/diagrams/radar/parser.ts create mode 100644 packages/mermaid/src/diagrams/radar/radar.spec.ts create mode 100644 packages/mermaid/src/diagrams/radar/renderer.ts create mode 100644 packages/mermaid/src/diagrams/radar/styles.ts create mode 100644 packages/mermaid/src/diagrams/radar/types.ts diff --git a/docs/community/contributing.md b/docs/community/contributing.md index 792c90a98..b545d03b0 100644 --- a/docs/community/contributing.md +++ b/docs/community/contributing.md @@ -239,6 +239,22 @@ Code is the heart of every software project. We strive to make it better. Who if The core of Mermaid is located under `packages/mermaid/src`. +### Building Mermaid Locally + +**Host** + +```bash +pnpm run build +``` + +**Docker** + +```bash +./run build +``` + +This will build the Mermaid library and the documentation site. + ### Running Mermaid Locally **Host** diff --git a/docs/config/setup/defaultConfig/variables/configKeys.md b/docs/config/setup/defaultConfig/variables/configKeys.md index 821b7aec6..4687ad8bc 100644 --- a/docs/config/setup/defaultConfig/variables/configKeys.md +++ b/docs/config/setup/defaultConfig/variables/configKeys.md @@ -12,4 +12,4 @@ > `const` **configKeys**: `Set`<`string`> -Defined in: [packages/mermaid/src/defaultConfig.ts:270](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L270) +Defined in: [packages/mermaid/src/defaultConfig.ts:274](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L274) diff --git a/docs/config/setup/mermaid/interfaces/MermaidConfig.md b/docs/config/setup/mermaid/interfaces/MermaidConfig.md index 7734e135b..d08533713 100644 --- a/docs/config/setup/mermaid/interfaces/MermaidConfig.md +++ b/docs/config/setup/mermaid/interfaces/MermaidConfig.md @@ -105,7 +105,7 @@ You can set this attribute to base the seed on a static string. > `optional` **dompurifyConfig**: `Config` -Defined in: [packages/mermaid/src/config.type.ts:202](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L202) +Defined in: [packages/mermaid/src/config.type.ts:203](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L203) --- @@ -167,7 +167,7 @@ See > `optional` **fontSize**: `number` -Defined in: [packages/mermaid/src/config.type.ts:204](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L204) +Defined in: [packages/mermaid/src/config.type.ts:205](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L205) --- @@ -280,7 +280,7 @@ Defines which main look to use for the diagram. > `optional` **markdownAutoWrap**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:205](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L205) +Defined in: [packages/mermaid/src/config.type.ts:206](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L206) --- @@ -336,6 +336,14 @@ Defined in: [packages/mermaid/src/config.type.ts:191](https://github.com/mermaid --- +### radar? + +> `optional` **radar**: `RadarDiagramConfig` + +Defined in: [packages/mermaid/src/config.type.ts:202](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L202) + +--- + ### requirement? > `optional` **requirement**: `RequirementDiagramConfig` @@ -404,7 +412,7 @@ Defined in: [packages/mermaid/src/config.type.ts:188](https://github.com/mermaid > `optional` **suppressErrorRendering**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:211](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L211) +Defined in: [packages/mermaid/src/config.type.ts:212](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L212) Suppresses inserting 'Syntax error' diagram in the DOM. This is useful when you want to control how to handle syntax errors in your application. @@ -450,7 +458,7 @@ Defined in: [packages/mermaid/src/config.type.ts:186](https://github.com/mermaid > `optional` **wrap**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:203](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L203) +Defined in: [packages/mermaid/src/config.type.ts:204](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L204) --- diff --git a/docs/syntax/radar.md b/docs/syntax/radar.md new file mode 100644 index 000000000..5fade0804 --- /dev/null +++ b/docs/syntax/radar.md @@ -0,0 +1,253 @@ +> **Warning** +> +> ## THIS IS AN AUTOGENERATED FILE. DO NOT EDIT. +> +> ## Please edit the corresponding file in [/packages/mermaid/src/docs/syntax/radar.md](../../packages/mermaid/src/docs/syntax/radar.md). + +# Radar Diagram (v\+) + +## Introduction + +A radar diagram is a simple way to plot low-dimensional data in a circular format. + +It is also known as a **radar chart**, **spider chart**, **star chart**, **cobweb chart**, **polar chart**, or **Kiviat diagram**. + +## Usage + +This diagram type is particularly useful for developers, data scientists, and engineers who require a clear and concise way to represent data in a circular format. + +It is commonly used to graphically summarize and compare the performance of multiple entities across multiple dimensions. + +## Syntax + +```md +radar-beta +axis A, B, C, D, E +curve c1{1,2,3,4,5} +curve c2{5,4,3,2,1} +... More Fields ... +``` + +## Examples + +```mermaid-example +--- +title: "Grades" +--- +radar-beta + axis m["Math"], s["Science"], e["English"] + axis h["History"], g["Geography"], a["Art"] + curve a["Alice"]{85, 90, 80, 70, 75, 90} + curve b["Bob"]{70, 75, 85, 80, 90, 85} + + max 100 + min 0 +``` + +```mermaid +--- +title: "Grades" +--- +radar-beta + axis m["Math"], s["Science"], e["English"] + axis h["History"], g["Geography"], a["Art"] + curve a["Alice"]{85, 90, 80, 70, 75, 90} + curve b["Bob"]{70, 75, 85, 80, 90, 85} + + max 100 + min 0 +``` + +```mermaid-example +radar-beta + title Restaurant Comparison + axis food["Food Quality"], service["Service"], price["Price"] + axis ambiance["Ambiance"], + + curve a["Restaurant A"]{4, 3, 2, 4} + curve b["Restaurant B"]{3, 4, 3, 3} + curve c["Restaurant C"]{2, 3, 4, 2} + curve d["Restaurant D"]{2, 2, 4, 3} + + graticule polygon + max 5 + +``` + +```mermaid +radar-beta + title Restaurant Comparison + axis food["Food Quality"], service["Service"], price["Price"] + axis ambiance["Ambiance"], + + curve a["Restaurant A"]{4, 3, 2, 4} + curve b["Restaurant B"]{3, 4, 3, 3} + curve c["Restaurant C"]{2, 3, 4, 2} + curve d["Restaurant D"]{2, 2, 4, 3} + + graticule polygon + max 5 + +``` + +## Details of Syntax + +### Title + +`title`: The title is an optional field that allows to render a title at the top of the radar diagram. + +``` +radar-beta + title Title of the Radar Diagram + ... +``` + +### Axis + +`axis`: The axis keyword is used to define the axes of the radar diagram. + +Each axis is represented by an ID and an optional label. + +Multiple axes can be defined in a single line. + +``` +radar-beta + axis id1["Label1"] + axis id2["Label2"], id3["Label3"] + ... +``` + +### Curve + +`curve`: The curve keyword is used to define the data points for a curve in the radar diagram. + +Each curve is represented by an ID, an optional label, and a list of values. + +Values can be defined by a list of numbers or a list of key-value pairs. If key-value pairs are used, the key represents the axis ID and the value represents the data point. Else, the data points are assumed to be in the order of the axes defined. + +Multiple curves can be defined in a single line. + +``` +radar-beta + axis axis1, axis2, axis3 + curve id1["Label1"]{1, 2, 3} + curve id2["Label2"]{4, 5, 6}, id3{7, 8, 9} + curve id4{ axis3: 30, axis1: 20, axis2: 10 } + ... +``` + +### Options + +- `showLegend`: The showLegend keyword is used to show or hide the legend in the radar diagram. The legend is shown by default. +- `max`: The maximum value for the radar diagram. This is used to scale the radar diagram. If not provided, the maximum value is calculated from the data points. +- `min`: The minimum value for the radar diagram. This is used to scale the radar diagram. If not provided, the minimum value is `0`. +- `graticule`: The graticule keyword is used to define the type of graticule to be rendered in the radar diagram. The graticule can be `circle` or `polygon`. If not provided, the default graticule is `circle`. +- `ticks`: The ticks keyword is used to define the number of ticks on the graticule. It is the number of concentric circles or polygons drawn to indicate the scale of the radar diagram. If not provided, the default number of ticks is `5`. + +``` +radar-beta + ... + showLegend true + max 100 + min 0 + graticule circle + ticks 5 + ... +``` + +## Configuration + +Please refer to the [configuration](/config/schema-docs/config-defs-radar-diagram-config.html) guide for details. + +| Parameter | Description | Default Value | +| --------------- | ---------------------------------------- | ------------- | +| width | Width of the radar diagram | `600` | +| height | Height of the radar diagram | `600` | +| marginTop | Top margin of the radar diagram | `50` | +| marginBottom | Bottom margin of the radar diagram | `50` | +| marginLeft | Left margin of the radar diagram | `50` | +| marginRight | Right margin of the radar diagram | `50` | +| axisScaleFactor | Scale factor for the axis | `1` | +| axisLabelFactor | Factor to adjust the axis label position | `1.05` | +| curveTension | Tension for the rounded curves | `0.17` | + +## Theme Variables + +### Global Theme Variables + +> **Note** +> The default values for these variables depend on the theme used. To override the default values, set the desired values in the themeVariables section of the configuration: +> %%{init: {"themeVariables": {"cScale0": "#FF0000", "cScale1": "#00FF00"}} }%% + +Radar charts support the color scales `cScale${i}` where `i` is a number from `0` to the theme's maximum number of colors in its color scale. Usually, the maximum number of colors is `12`. + +| Property | Description | +| ---------- | ------------------------------ | +| fontSize | Font size of the title | +| titleColor | Color of the title | +| cScale${i} | Color scale for the i-th curve | + +### Radar Style Options + +> **Note** +> Specific variables for radar resides inside the `radar` key. To set the radar style options, use this syntax. +> %%{init: {"themeVariables": {"radar": {"axisColor": "#FF0000"}} } }%% + +| Property | Description | Default Value | +| -------------------- | ---------------------------- | ------------- | +| axisColor | Color of the axis lines | `black` | +| axisStrokeWidth | Width of the axis lines | `1` | +| axisLabelFontSize | Font size of the axis labels | `12px` | +| curveOpacity | Opacity of the curves | `0.7` | +| curveStrokeWidth | Width of the curves | `2` | +| graticuleColor | Color of the graticule | `black` | +| graticuleOpacity | Opacity of the graticule | `0.5` | +| graticuleStrokeWidth | Width of the graticule | `1` | +| legendBoxSize | Size of the legend box | `10` | +| legendFontSize | Font size of the legend | `14px` | + +## Example on config and theme + +```mermaid-example +--- +config: + radar: + axisScaleFactor: 0.25 + curveTension: 0.1 + theme: base + themeVariables: + cScale0: "#FF0000" + cScale1: "#00FF00" + cScale2: "#0000FF" + radar: + curveOpacity: 0 +--- +radar-beta + axis A, B, C, D, E + curve c1{1,2,3,4,5} + curve c2{5,4,3,2,1} + curve c3{3,3,3,3,3} +``` + +```mermaid +--- +config: + radar: + axisScaleFactor: 0.25 + curveTension: 0.1 + theme: base + themeVariables: + cScale0: "#FF0000" + cScale1: "#00FF00" + cScale2: "#0000FF" + radar: + curveOpacity: 0 +--- +radar-beta + axis A, B, C, D, E + curve c1{1,2,3,4,5} + curve c2{5,4,3,2,1} + curve c3{3,3,3,3,3} +``` + + diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index 60a1fc2c1..c02a41a1c 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -199,6 +199,7 @@ export interface MermaidConfig { sankey?: SankeyDiagramConfig; packet?: PacketDiagramConfig; block?: BlockDiagramConfig; + radar?: RadarDiagramConfig; dompurifyConfig?: DOMPurifyConfiguration; wrap?: boolean; fontSize?: number; @@ -1526,6 +1527,50 @@ export interface PacketDiagramConfig extends BaseDiagramConfig { export interface BlockDiagramConfig extends BaseDiagramConfig { padding?: number; } +/** + * The object containing configurations specific for radar diagrams. + * + * This interface was referenced by `MermaidConfig`'s JSON-Schema + * via the `definition` "RadarDiagramConfig". + */ +export interface RadarDiagramConfig extends BaseDiagramConfig { + /** + * The size of the radar diagram. + */ + width?: number; + /** + * The size of the radar diagram. + */ + height?: number; + /** + * The margin from the top of the radar diagram. + */ + marginTop?: number; + /** + * The margin from the right of the radar diagram. + */ + marginRight?: number; + /** + * The margin from the bottom of the radar diagram. + */ + marginBottom?: number; + /** + * The margin from the left of the radar diagram. + */ + marginLeft?: number; + /** + * The scale factor of the axis. + */ + axisScaleFactor?: number; + /** + * The scale factor of the axis label. + */ + axisLabelFactor?: number; + /** + * The tension factor for the Catmull-Rom spline conversion to cubic Bézier curves. + */ + curveTension?: number; +} /** * This interface was referenced by `MermaidConfig`'s JSON-Schema * via the `definition` "FontConfig". diff --git a/packages/mermaid/src/defaultConfig.ts b/packages/mermaid/src/defaultConfig.ts index a3dab2ddb..2e4e20f50 100644 --- a/packages/mermaid/src/defaultConfig.ts +++ b/packages/mermaid/src/defaultConfig.ts @@ -255,8 +255,12 @@ const config: RequiredDeep = { packet: { ...defaultConfigJson.packet, }, + radar: { + ...defaultConfigJson.radar, + }, }; +// eslint-disable-next-line @typescript-eslint/no-explicit-any const keyify = (obj: any, prefix = ''): string[] => Object.keys(obj).reduce((res: string[], el): string[] => { if (Array.isArray(obj[el])) { diff --git a/packages/mermaid/src/diagram-api/diagram-orchestration.ts b/packages/mermaid/src/diagram-api/diagram-orchestration.ts index 5b8cfc3fe..8f2b76abb 100644 --- a/packages/mermaid/src/diagram-api/diagram-orchestration.ts +++ b/packages/mermaid/src/diagram-api/diagram-orchestration.ts @@ -22,6 +22,7 @@ import mindmap from '../diagrams/mindmap/detector.js'; import kanban from '../diagrams/kanban/detector.js'; import sankey from '../diagrams/sankey/sankeyDetector.js'; import { packet } from '../diagrams/packet/detector.js'; +import { radar } from '../diagrams/radar/detector.js'; import block from '../diagrams/block/blockDetector.js'; import architecture from '../diagrams/architecture/architectureDetector.js'; import { registerLazyLoadedDiagrams } from './detectType.js'; @@ -94,6 +95,7 @@ export const addDiagrams = () => { packet, xychart, block, - architecture + architecture, + radar ); }; diff --git a/packages/mermaid/src/diagrams/radar/db.ts b/packages/mermaid/src/diagrams/radar/db.ts new file mode 100644 index 000000000..ff95a3be0 --- /dev/null +++ b/packages/mermaid/src/diagrams/radar/db.ts @@ -0,0 +1,128 @@ +import { getConfig as commonGetConfig } from '../../config.js'; +import type { RadarDiagramConfig } from '../../config.type.js'; +import DEFAULT_CONFIG from '../../defaultConfig.js'; +import { cleanAndMerge } from '../../utils.js'; +import { + clear as commonClear, + getAccDescription, + getAccTitle, + getDiagramTitle, + setAccDescription, + setAccTitle, + setDiagramTitle, +} from '../common/commonDb.js'; +import type { + Axis, + Curve, + Option, + Entry, +} from '../../../../parser/dist/src/language/generated/ast.js'; +import type { RadarAxis, RadarCurve, RadarOptions, RadarDB, RadarData } from './types.js'; + +const defaultOptions: RadarOptions = { + showLegend: true, + ticks: 5, + max: null, + min: 0, + graticule: 'circle', +}; + +const defaultRadarData: RadarData = { + axes: [], + curves: [], + options: defaultOptions, +}; + +let data: RadarData = structuredClone(defaultRadarData); + +const DEFAULT_RADAR_CONFIG: Required = DEFAULT_CONFIG.radar; + +const getConfig = (): Required => { + const config = cleanAndMerge({ + ...DEFAULT_RADAR_CONFIG, + ...commonGetConfig().radar, + }); + return config; +}; + +const getAxes = (): RadarAxis[] => data.axes; +const getCurves = (): RadarCurve[] => data.curves; +const getOptions = (): RadarOptions => data.options; + +const setAxes = (axes: Axis[]) => { + data.axes = axes.map((axis) => { + return { + name: axis.name, + label: axis.label ?? axis.name, + }; + }); +}; + +const setCurves = (curves: Curve[]) => { + data.curves = curves.map((curve) => { + return { + name: curve.name, + label: curve.label ?? curve.name, + entries: computeCurveEntries(curve.entries), + }; + }); +}; + +const computeCurveEntries = (entries: Entry[]): number[] => { + // If entries have axis reference, we must order them according to the axes + if (entries[0].axis == undefined) { + return entries.map((entry) => entry.value); + } + const axes = getAxes(); + if (axes.length === 0) { + throw new Error('Axes must be populated before curves for reference entries'); + } + return axes.map((axis) => { + const entry = entries.find((entry) => entry.axis?.$refText === axis.name); + if (entry === undefined) { + throw new Error('Missing entry for axis ' + axis.label); + } + return entry.value; + }); +}; + +const setOptions = (options: Option[]) => { + // Create a map from option names to option objects for quick lookup + const optionMap = options.reduce( + (acc, option) => { + acc[option.name] = option; + return acc; + }, + {} as Record + ); + + data.options = { + showLegend: (optionMap.showLegend?.value as boolean) ?? defaultOptions.showLegend, + ticks: (optionMap.ticks?.value as number) ?? defaultOptions.ticks, + max: (optionMap.max?.value as number) ?? defaultOptions.max, + min: (optionMap.min?.value as number) ?? defaultOptions.min, + graticule: (optionMap.graticule?.value as 'circle' | 'polygon') ?? defaultOptions.graticule, + }; +}; + +const clear = () => { + commonClear(); + data = structuredClone(defaultRadarData); +}; + +export const db: RadarDB = { + getAxes, + getCurves, + getOptions, + setAxes, + setCurves, + setOptions, + getConfig, + clear, + setAccTitle, + getAccTitle, + setDiagramTitle, + getDiagramTitle, + getAccDescription, + setAccDescription, +}; diff --git a/packages/mermaid/src/diagrams/radar/detector.ts b/packages/mermaid/src/diagrams/radar/detector.ts new file mode 100644 index 000000000..9c29d0f00 --- /dev/null +++ b/packages/mermaid/src/diagrams/radar/detector.ts @@ -0,0 +1,22 @@ +import type { + DiagramDetector, + DiagramLoader, + ExternalDiagramDefinition, +} from '../../diagram-api/types.js'; + +const id = 'radar'; + +const detector: DiagramDetector = (txt) => { + return /^\s*radar-beta/.test(txt); +}; + +const loader: DiagramLoader = async () => { + const { diagram } = await import('./diagram.js'); + return { id, diagram }; +}; + +export const radar: ExternalDiagramDefinition = { + id, + detector, + loader, +}; diff --git a/packages/mermaid/src/diagrams/radar/diagram.ts b/packages/mermaid/src/diagrams/radar/diagram.ts new file mode 100644 index 000000000..a73a77c05 --- /dev/null +++ b/packages/mermaid/src/diagrams/radar/diagram.ts @@ -0,0 +1,12 @@ +import type { DiagramDefinition } from '../../diagram-api/types.js'; +import { db } from './db.js'; +import { parser } from './parser.js'; +import { renderer } from './renderer.js'; +import { styles } from './styles.js'; + +export const diagram: DiagramDefinition = { + parser, + db, + renderer, + styles, +}; diff --git a/packages/mermaid/src/diagrams/radar/parser.ts b/packages/mermaid/src/diagrams/radar/parser.ts new file mode 100644 index 000000000..e16e9b3dd --- /dev/null +++ b/packages/mermaid/src/diagrams/radar/parser.ts @@ -0,0 +1,23 @@ +import type { Radar } from '@mermaid-js/parser'; +import { parse } from '@mermaid-js/parser'; +import type { ParserDefinition } from '../../diagram-api/types.js'; +import { log } from '../../logger.js'; +import { populateCommonDb } from '../common/populateCommonDb.js'; +import { db } from './db.js'; + +const populate = (ast: Radar) => { + populateCommonDb(ast, db); + const { axes, curves, options } = ast; + // Here we can add specific logic between the AST and the DB + db.setAxes(axes); + db.setCurves(curves); + db.setOptions(options); +}; + +export const parser: ParserDefinition = { + parse: async (input: string): Promise => { + const ast: Radar = await parse('radar', input); + log.debug(ast); + populate(ast); + }, +}; diff --git a/packages/mermaid/src/diagrams/radar/radar.spec.ts b/packages/mermaid/src/diagrams/radar/radar.spec.ts new file mode 100644 index 000000000..9971e8b17 --- /dev/null +++ b/packages/mermaid/src/diagrams/radar/radar.spec.ts @@ -0,0 +1,167 @@ +import { it, describe, expect } from 'vitest'; +import { db } from './db.js'; +import { parser } from './parser.js'; + +const { + clear, + getDiagramTitle, + getAccTitle, + getAccDescription, + getAxes, + getCurves, + getOptions, + getConfig, +} = db; + +describe('radar diagrams', () => { + beforeEach(() => { + clear(); + }); + + it('should handle a simple radar definition', async () => { + const str = `radar-beta + axis A,B,C + curve mycurve{1,2,3}`; + await expect(parser.parse(str)).resolves.not.toThrow(); + }); + + it('should handle diagram with data and title', async () => { + const str = `radar-beta + title Radar diagram + accTitle: Radar accTitle + accDescr: Radar accDescription + axis A["Axis A"], B["Axis B"] ,C["Axis C"] + curve mycurve["My Curve"]{1,2,3} + `; + await expect(parser.parse(str)).resolves.not.toThrow(); + expect(getDiagramTitle()).toMatchInlineSnapshot('"Radar diagram"'); + expect(getAccTitle()).toMatchInlineSnapshot('"Radar accTitle"'); + expect(getAccDescription()).toMatchInlineSnapshot('"Radar accDescription"'); + expect(getAxes()).toMatchInlineSnapshot(` + [ + { + "label": "Axis A", + "name": "A", + }, + { + "label": "Axis B", + "name": "B", + }, + { + "label": "Axis C", + "name": "C", + }, + ] + `); + expect(getCurves()).toMatchInlineSnapshot(` + [ + { + "entries": [ + 1, + 2, + 3, + ], + "label": "My Curve", + "name": "mycurve", + }, + ] + `); + expect(getOptions()).toMatchInlineSnapshot(` + { + "graticule": "circle", + "max": null, + "min": 0, + "showLegend": true, + "ticks": 5, + } + `); + }); + + it('should handle a radar diagram with options', async () => { + const str = `radar-beta + ticks 10 + showLegend false + graticule polygon + min 1 + max 10 + `; + await expect(parser.parse(str)).resolves.not.toThrow(); + expect(getOptions()).toMatchInlineSnapshot(` + { + "graticule": "polygon", + "max": 10, + "min": 1, + "showLegend": false, + "ticks": 10, + } + `); + }); + + it('should handle curve with detailed data in any order', async () => { + const str = `radar-beta + axis A,B,C + curve mycurve{ C: 3, A: 1, B: 2 }`; + await expect(parser.parse(str)).resolves.not.toThrow(); + expect(getCurves()).toMatchInlineSnapshot(` + [ + { + "entries": [ + 1, + 2, + 3, + ], + "label": "mycurve", + "name": "mycurve", + }, + ] + `); + }); + + it('should handle radar diagram with comments', async () => { + const str = `radar-beta + %% This is a comment + axis A,B,C + %% This is another comment + curve mycurve{1,2,3} + `; + await expect(parser.parse(str)).resolves.not.toThrow(); + }); + + it('should handle radar diagram with config override', async () => { + const str = ` + %%{init: {'radar': {'marginTop': 80, 'axisLabelFactor': 1.25}}}%% + radar-beta + axis A,B,C + curve mycurve{1,2,3} + `; + await expect(parser.parse(str)).resolves.not.toThrow(); + + // TODO: ✨ Fix this test + // expect(getConfig().marginTop).toBe(80); + // expect(getConfig().axisLabelFactor).toBe(1.25); + }); + + it('should parse radar diagram with theme override', async () => { + const str = ` + %%{init: { "theme": "base", "themeVariables": {'fontSize': 80, 'cScale0': '#123456' }}}%% + radar-beta: + axis A,B,C + curve mycurve{1,2,3} + `; + await expect(parser.parse(str)).resolves.not.toThrow(); + + // TODO: ✨ Add tests for theme override + }); + + it('should handle radar diagram with radar style override', async () => { + const str = ` + %%{init: { "theme": "base", "themeVariables": {'fontSize': 10, 'radar': { 'axisColor': '#FF0000' }}}}%% + radar-beta + axis A,B,C + curve mycurve{1,2,3} + `; + await expect(parser.parse(str)).resolves.not.toThrow(); + + // TODO: ✨ Add tests for style override + }); +}); diff --git a/packages/mermaid/src/diagrams/radar/renderer.ts b/packages/mermaid/src/diagrams/radar/renderer.ts new file mode 100644 index 000000000..5a5b47f45 --- /dev/null +++ b/packages/mermaid/src/diagrams/radar/renderer.ts @@ -0,0 +1,226 @@ +import type { Diagram } from '../../Diagram.js'; +import type { RadarDiagramConfig } from '../../config.type.js'; +import type { DiagramRenderer, DrawDefinition, SVG, SVGGroup } from '../../diagram-api/types.js'; +import { selectSvgElement } from '../../rendering-util/selectSvgElement.js'; +import type { RadarDB, RadarAxis, RadarCurve } from './types.js'; + +const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { + const db = diagram.db as RadarDB; + const axes = db.getAxes(); + const curves = db.getCurves(); + const options = db.getOptions(); + const config = db.getConfig(); + const title = db.getDiagramTitle(); + + const svg: SVG = selectSvgElement(id); + + // 🖼️ Draw the main frame + const g = drawFrame(svg, config); + + // The maximum value for the radar chart is the 'max' option if it exists, + // otherwise it is the maximum value of the curves + const maxValue: number = + options.max ?? Math.max(...curves.map((curve) => Math.max(...curve.entries))); + const minValue: number = options.min; + const radius = Math.min(config.width, config.height) / 2; + + // 🕸️ Draw graticule + drawGraticule(g, axes, radius, options.ticks, options.graticule); + + // 🪓 Draw the axes + drawAxes(g, axes, radius, config); + + // 📊 Draw the curves + drawCurves(g, axes, curves, minValue, maxValue, options.graticule, config); + + // 🏷 Draw Legend + drawLegend(g, curves, options.showLegend, config); + + // 🏷 Draw Title + g.append('text') + .attr('class', 'radarTitle') + .text(title) + .attr('x', 0) + .attr('y', -config.height / 2 - config.marginTop); +}; + +// Returns a g element to center the radar chart +// it is of type SVGElement +const drawFrame = (svg: SVG, config: Required): SVGGroup => { + const totalWidth = config.width + config.marginLeft + config.marginRight; + const totalHeight = config.height + config.marginTop + config.marginBottom; + const center = { + x: config.marginLeft + config.width / 2, + y: config.marginTop + config.height / 2, + }; + // Initialize the SVG + svg + .attr('viewbox', `0 0 ${totalWidth} ${totalHeight}`) + .attr('width', totalWidth) + .attr('height', totalHeight); + // g element to center the radar chart + return svg.append('g').attr('transform', `translate(${center.x}, ${center.y})`); +}; + +const drawGraticule = ( + g: SVGGroup, + axes: RadarAxis[], + radius: number, + ticks: number, + graticule: string +) => { + if (graticule === 'circle') { + // Draw a circle for each tick + for (let i = 0; i < ticks; i++) { + const r = (radius * (i + 1)) / ticks; + g.append('circle').attr('r', r).attr('class', 'radarGraticule'); + } + } else if (graticule === 'polygon') { + // Draw a polygon + const numAxes = axes.length; + for (let i = 0; i < ticks; i++) { + const r = (radius * (i + 1)) / ticks; + const points = axes + .map((_, j) => { + const angle = (2 * j * Math.PI) / numAxes - Math.PI / 2; + const x = r * Math.cos(angle); + const y = r * Math.sin(angle); + return `${x},${y}`; + }) + .join(' '); + g.append('polygon').attr('points', points).attr('class', 'radarGraticule'); + } + } +}; + +const drawAxes = ( + g: SVGGroup, + axes: RadarAxis[], + radius: number, + config: Required +) => { + const numAxes = axes.length; + + for (let i = 0; i < numAxes; i++) { + const label = axes[i].label; + const angle = (2 * i * Math.PI) / numAxes - Math.PI / 2; + g.append('line') + .attr('x1', 0) + .attr('y1', 0) + .attr('x2', radius * config.axisScaleFactor * Math.cos(angle)) + .attr('y2', radius * config.axisScaleFactor * Math.sin(angle)) + .attr('class', 'radarAxisLine'); + g.append('text') + .text(label) + .attr('x', radius * config.axisLabelFactor * Math.cos(angle)) + .attr('y', radius * config.axisLabelFactor * Math.sin(angle)) + .attr('class', 'radarAxisLabel'); + } +}; + +export const renderer: DiagramRenderer = { draw }; +function drawCurves( + g: SVGGroup, + axes: RadarAxis[], + curves: RadarCurve[], + minValue: number, + maxValue: number, + graticule: string, + config: Required +) { + const numAxes = axes.length; + const radius = Math.min(config.width, config.height) / 2; + + curves.forEach((curve, index) => { + if (curve.entries.length !== numAxes) { + // Skip curves that do not have an entry for each axis. + return; + } + // Compute points for the curve. + const points = curve.entries.map((entry, i) => { + const angle = (2 * Math.PI * i) / numAxes - Math.PI / 2; + const r = relativeRadius(entry, minValue, maxValue, radius); + const x = r * Math.cos(angle); + const y = r * Math.sin(angle); + return { x, y }; + }); + + if (graticule === 'circle') { + // Draw a closed curve through the points. + g.append('path') + .attr('d', closedRoundCurve(points, config.curveTension)) + .attr('class', `radarCurve-${index}`); + } else if (graticule === 'polygon') { + // Draw a polygon for each curve. + g.append('polygon') + .attr('points', points.map((p) => `${p.x},${p.y}`).join(' ')) + .attr('class', `radarCurve-${index}`); + } + }); +} + +function relativeRadius(value: number, minValue: number, maxValue: number, radius: number): number { + const clippedValue = Math.min(Math.max(value, minValue), maxValue); + return (radius * (clippedValue - minValue)) / (maxValue - minValue); +} + +function closedRoundCurve(points: { x: number; y: number }[], tension: number): string { + // Catmull-Rom spline helper function + const numPoints = points.length; + let d = `M${points[0].x},${points[0].y}`; + // For each segment from point i to point (i+1) mod n, compute control points. + for (let i = 0; i < numPoints; i++) { + const p0 = points[(i - 1 + numPoints) % numPoints]; + const p1 = points[i]; + const p2 = points[(i + 1) % numPoints]; + const p3 = points[(i + 2) % numPoints]; + // Calculate the control points for the cubic Bezier segment + const cp1 = { + x: p1.x + (p2.x - p0.x) * tension, + y: p1.y + (p2.y - p0.y) * tension, + }; + const cp2 = { + x: p2.x - (p3.x - p1.x) * tension, + y: p2.y - (p3.y - p1.y) * tension, + }; + d += ` C${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${p2.x},${p2.y}`; + } + return `${d} Z`; +} + +function drawLegend( + g: SVGGroup, + curves: RadarCurve[], + showLegend: boolean, + config: Required +) { + if (!showLegend) { + return; + } + + // Create a legend group and position it in the top-right corner of the chart. + const legendX = ((config.width / 2 + config.marginRight) * 3) / 4; + const legendY = (-(config.height / 2 + config.marginTop) * 3) / 4; + const lineHeight = 20; + + curves.forEach((curve, index) => { + const itemGroup = g + .append('g') + .attr('transform', `translate(${legendX}, ${legendY + index * lineHeight})`); + + // Draw a square marker for this curve. + itemGroup + .append('rect') + .attr('width', 12) + .attr('height', 12) + .attr('class', `radarLegendBox-${index}`); + + // Draw the label text next to the marker. + itemGroup + .append('text') + .attr('x', 16) + .attr('y', 0) + .attr('class', 'radarLegendText') + .text(curve.label); + }); +} diff --git a/packages/mermaid/src/diagrams/radar/styles.ts b/packages/mermaid/src/diagrams/radar/styles.ts new file mode 100644 index 000000000..591385507 --- /dev/null +++ b/packages/mermaid/src/diagrams/radar/styles.ts @@ -0,0 +1,71 @@ +import type { DiagramStylesProvider } from '../../diagram-api/types.js'; +import { cleanAndMerge } from '../../utils.js'; +import type { RadarStyleOptions } from './types.js'; +import { getThemeVariables } from '../../themes/theme-default.js'; +import { getConfig as getConfigAPI } from '../../config.js'; + +const genIndexStyles = ( + themeVariables: ReturnType, + radarOptions: RadarStyleOptions +) => { + let sections = ''; + for (let i = 0; i < themeVariables.THEME_COLOR_LIMIT; i++) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const indexColor = (themeVariables as any)[`cScale${i}`]; + sections += ` + .radarCurve-${i} { + color: ${indexColor}; + fill: ${indexColor}; + fill-opacity: ${radarOptions.curveOpacity}; + stroke: ${indexColor}; + stroke-width: ${radarOptions.curveStrokeWidth}; + } + .radarLegendBox-${i} { + fill: ${indexColor}; + fill-opacity: ${radarOptions.curveOpacity}; + stroke: ${indexColor}; + } + `; + } + return sections; +}; + +export const styles: DiagramStylesProvider = ({ radar }: { radar?: RadarStyleOptions } = {}) => { + const defaultThemeVariables = getThemeVariables(); + const currentConfig = getConfigAPI(); + + const themeVariables = cleanAndMerge(defaultThemeVariables, currentConfig.themeVariables); + const radarOptions: RadarStyleOptions = cleanAndMerge(themeVariables.radar, radar); + return ` + .radarTitle { + font-size: ${themeVariables.fontSize}; + text-color: ${themeVariables.titleColor}; + dominant-baseline: hanging; + text-anchor: middle; + } + .radarAxisLine { + stroke: ${radarOptions.axisColor}; + stroke-width: ${radarOptions.axisStrokeWidth}; + } + .radarAxisLabel { + dominant-baseline: middle; + text-anchor: middle; + font-size: ${radarOptions.axisLabelFontSize}px; + color: ${radarOptions.axisColor}; + } + .radarGraticule { + fill: ${radarOptions.graticuleColor}; + fill-opacity: ${radarOptions.graticuleOpacity}; + stroke: ${radarOptions.graticuleColor}; + stroke-width: ${radarOptions.graticuleStrokeWidth}; + } + .radarLegendText { + text-anchor: start; + font-size: ${radarOptions.legendFontSize}px; + dominant-baseline: hanging; + } + ${genIndexStyles(themeVariables, radarOptions)} + `; +}; + +export default styles; diff --git a/packages/mermaid/src/diagrams/radar/types.ts b/packages/mermaid/src/diagrams/radar/types.ts new file mode 100644 index 000000000..307fbaf04 --- /dev/null +++ b/packages/mermaid/src/diagrams/radar/types.ts @@ -0,0 +1,47 @@ +import type { Axis, Curve, Option } from '../../../../parser/dist/src/language/generated/ast.js'; +import type { RadarDiagramConfig } from '../../config.type.js'; +import type { DiagramDBBase } from '../../diagram-api/types.js'; + +export interface RadarAxis { + name: string; + label: string; +} +export interface RadarCurve { + name: string; + entries: number[]; + label: string; +} +export interface RadarOptions { + showLegend: boolean; + ticks: number; + max: number | null; + min: number; + graticule: 'circle' | 'polygon'; +} +export interface RadarDB extends DiagramDBBase { + getAxes: () => RadarAxis[]; + getCurves: () => RadarCurve[]; + getOptions: () => RadarOptions; + setAxes: (axes: Axis[]) => void; + setCurves: (curves: Curve[]) => void; + setOptions: (options: Option[]) => void; +} + +export interface RadarStyleOptions { + axisColor: string; + axisStrokeWidth: number; + axisLabelFontSize: number; + curveOpacity: number; + curveStrokeWidth: number; + graticuleColor: string; + graticuleOpacity: number; + graticuleStrokeWidth: number; + legendBoxSize: number; + legendFontSize: number; +} + +export interface RadarData { + axes: RadarAxis[]; + curves: RadarCurve[]; + options: RadarOptions; +} diff --git a/packages/mermaid/src/mermaidAPI.spec.ts b/packages/mermaid/src/mermaidAPI.spec.ts index c3480d203..64f4b8d60 100644 --- a/packages/mermaid/src/mermaidAPI.spec.ts +++ b/packages/mermaid/src/mermaidAPI.spec.ts @@ -30,6 +30,7 @@ vi.mock('./diagrams/packet/renderer.js'); vi.mock('./diagrams/xychart/xychartRenderer.js'); vi.mock('./diagrams/requirement/requirementRenderer.js'); vi.mock('./diagrams/sequence/sequenceRenderer.js'); +vi.mock('./diagrams/radar/renderer.js'); // ------------------------------------- @@ -797,6 +798,7 @@ graph TD;A--x|text including URL space|B;`) { textDiagramType: 'requirementDiagram', expectedType: 'requirement' }, { textDiagramType: 'sequenceDiagram', expectedType: 'sequence' }, { textDiagramType: 'stateDiagram-v2', expectedType: 'stateDiagram' }, + { textDiagramType: 'radar-beta', expectedType: 'radar' }, ]; describe('accessibility', () => { diff --git a/packages/mermaid/src/schemas/config.schema.yaml b/packages/mermaid/src/schemas/config.schema.yaml index e45dd44be..1445e2dd2 100644 --- a/packages/mermaid/src/schemas/config.schema.yaml +++ b/packages/mermaid/src/schemas/config.schema.yaml @@ -55,6 +55,7 @@ required: - packet - block - look + - radar properties: theme: description: | @@ -292,6 +293,8 @@ properties: $ref: '#/$defs/PacketDiagramConfig' block: $ref: '#/$defs/BlockDiagramConfig' + radar: + $ref: '#/$defs/RadarDiagramConfig' dompurifyConfig: title: DOM Purify Configuration description: Configuration options to pass to the `dompurify` library. @@ -2208,6 +2211,60 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file) minimum: 0 default: 8 + RadarDiagramConfig: + title: Radar Diagram Config + allOf: [{ $ref: '#/$defs/BaseDiagramConfig' }] + description: The object containing configurations specific for radar diagrams. + type: object + unevaluatedProperties: false + properties: + width: + description: The size of the radar diagram. + type: number + minimum: 1 + default: 600 + height: + description: The size of the radar diagram. + type: number + minimum: 1 + default: 600 + marginTop: + description: The margin from the top of the radar diagram. + type: number + minimum: 0 + default: 50 + marginRight: + description: The margin from the right of the radar diagram. + type: number + minimum: 0 + default: 50 + marginBottom: + description: The margin from the bottom of the radar diagram. + type: number + minimum: 0 + default: 50 + marginLeft: + description: The margin from the left of the radar diagram. + type: number + minimum: 0 + default: 50 + axisScaleFactor: + description: The scale factor of the axis. + type: number + minimum: 0 + default: 1 + axisLabelFactor: + description: The scale factor of the axis label. + type: number + minimum: 0 + default: 1.05 + curveTension: + description: The tension factor for the Catmull-Rom spline conversion to cubic Bézier curves. + type: number + minimum: 0 + maximum: 1 + default: 0.17 + FontCalculator: title: Font Calculator description: | diff --git a/packages/mermaid/src/styles.spec.ts b/packages/mermaid/src/styles.spec.ts index 70e9e7ec5..8c5263edc 100644 --- a/packages/mermaid/src/styles.spec.ts +++ b/packages/mermaid/src/styles.spec.ts @@ -29,6 +29,7 @@ import timeline from './diagrams/timeline/styles.js'; import mindmap from './diagrams/mindmap/styles.js'; import packet from './diagrams/packet/styles.js'; import block from './diagrams/block/styles.js'; +import radar from './diagrams/radar/styles.js'; import themes from './themes/index.js'; function checkValidStylisCSSStyleSheet(stylisString: string) { @@ -99,6 +100,7 @@ describe('styles', () => { block, timeline, packet, + radar, })) { test(`should return a valid style for diagram ${diagramId} and theme ${themeId}`, async () => { const { default: getStyles, addStylesForDiagram } = await import('./styles.js'); From d53c66dde5797038dfa6ee1d6df3e419a00ac22b Mon Sep 17 00:00:00 2001 From: Thomas Di Cizerone Date: Sun, 16 Mar 2025 18:15:30 +0100 Subject: [PATCH 03/17] =?UTF-8?q?=F0=9F=8E=A8=20Add=20themes=20for=20radar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/mermaid/src/themes/theme-base.js | 14 ++++++++++++++ packages/mermaid/src/themes/theme-dark.js | 14 ++++++++++++++ packages/mermaid/src/themes/theme-default.js | 14 ++++++++++++++ packages/mermaid/src/themes/theme-forest.js | 14 ++++++++++++++ packages/mermaid/src/themes/theme-neutral.js | 14 ++++++++++++++ 5 files changed, 70 insertions(+) diff --git a/packages/mermaid/src/themes/theme-base.js b/packages/mermaid/src/themes/theme-base.js index 1f858275f..73ffef070 100644 --- a/packages/mermaid/src/themes/theme-base.js +++ b/packages/mermaid/src/themes/theme-base.js @@ -230,6 +230,20 @@ class Theme { this.pieOuterStrokeColor = this.pieOuterStrokeColor || 'black'; this.pieOpacity = this.pieOpacity || '0.7'; + /* radar */ + this.radar = { + axisColor: this.radar?.axisColor || this.lineColor, + axisStrokeWidth: this.radar?.axisStrokeWidth || 2, + axisLabelFontSize: this.radar?.axisLabelFontSize || 12, + curveOpacity: this.radar?.curveOpacity || 0.5, + curveStrokeWidth: this.radar?.curveStrokeWidth || 2, + graticuleColor: this.radar?.graticuleColor || '#DEDEDE', + graticuleStrokeWidth: this.radar?.graticuleStrokeWidth || 1, + graticuleOpacity: this.radar?.graticuleOpacity || 0.3, + legendBoxSize: this.radar?.legendBoxSize || 12, + legendFontSize: this.radar?.legendFontSize || 12, + }; + /* architecture */ this.archEdgeColor = this.archEdgeColor || '#777'; this.archEdgeArrowColor = this.archEdgeArrowColor || '#777'; diff --git a/packages/mermaid/src/themes/theme-dark.js b/packages/mermaid/src/themes/theme-dark.js index a0df8d4f3..c452eea9f 100644 --- a/packages/mermaid/src/themes/theme-dark.js +++ b/packages/mermaid/src/themes/theme-dark.js @@ -291,6 +291,20 @@ class Theme { blockFillColor: this.background, }; + /* radar */ + this.radar = { + axisColor: this.radar?.axisColor || this.lineColor, + axisStrokeWidth: this.radar?.axisStrokeWidth || 2, + axisLabelFontSize: this.radar?.axisLabelFontSize || 12, + curveOpacity: this.radar?.curveOpacity || 0.5, + curveStrokeWidth: this.radar?.curveStrokeWidth || 2, + graticuleColor: this.radar?.graticuleColor || '#DEDEDE', + graticuleStrokeWidth: this.radar?.graticuleStrokeWidth || 1, + graticuleOpacity: this.radar?.graticuleOpacity || 0.3, + legendBoxSize: this.radar?.legendBoxSize || 12, + legendFontSize: this.radar?.legendFontSize || 12, + }; + /* class */ this.classText = this.primaryTextColor; diff --git a/packages/mermaid/src/themes/theme-default.js b/packages/mermaid/src/themes/theme-default.js index 78f20a475..eba3ff101 100644 --- a/packages/mermaid/src/themes/theme-default.js +++ b/packages/mermaid/src/themes/theme-default.js @@ -291,6 +291,20 @@ class Theme { this.quadrantExternalBorderStrokeFill || this.primaryBorderColor; this.quadrantTitleFill = this.quadrantTitleFill || this.primaryTextColor; + /* radar */ + this.radar = { + axisColor: this.radar?.axisColor || this.lineColor, + axisStrokeWidth: this.radar?.axisStrokeWidth || 2, + axisLabelFontSize: this.radar?.axisLabelFontSize || 12, + curveOpacity: this.radar?.curveOpacity || 0.5, + curveStrokeWidth: this.radar?.curveStrokeWidth || 2, + graticuleColor: this.radar?.graticuleColor || '#DEDEDE', + graticuleStrokeWidth: this.radar?.graticuleStrokeWidth || 1, + graticuleOpacity: this.radar?.graticuleOpacity || 0.3, + legendBoxSize: this.radar?.legendBoxSize || 12, + legendFontSize: this.radar?.legendFontSize || 12, + }; + /* xychart */ this.xyChart = { backgroundColor: this.xyChart?.backgroundColor || this.background, diff --git a/packages/mermaid/src/themes/theme-forest.js b/packages/mermaid/src/themes/theme-forest.js index 34d965201..853b4d032 100644 --- a/packages/mermaid/src/themes/theme-forest.js +++ b/packages/mermaid/src/themes/theme-forest.js @@ -265,6 +265,20 @@ class Theme { blockFillColor: this.mainBkg, }; + /* radar */ + this.radar = { + axisColor: this.radar?.axisColor || this.lineColor, + axisStrokeWidth: this.radar?.axisStrokeWidth || 2, + axisLabelFontSize: this.radar?.axisLabelFontSize || 12, + curveOpacity: this.radar?.curveOpacity || 0.5, + curveStrokeWidth: this.radar?.curveStrokeWidth || 2, + graticuleColor: this.radar?.graticuleColor || '#DEDEDE', + graticuleStrokeWidth: this.radar?.graticuleStrokeWidth || 1, + graticuleOpacity: this.radar?.graticuleOpacity || 0.3, + legendBoxSize: this.radar?.legendBoxSize || 12, + legendFontSize: this.radar?.legendFontSize || 12, + }; + /* xychart */ this.xyChart = { backgroundColor: this.xyChart?.backgroundColor || this.background, diff --git a/packages/mermaid/src/themes/theme-neutral.js b/packages/mermaid/src/themes/theme-neutral.js index 8b7f34ed8..633a26849 100644 --- a/packages/mermaid/src/themes/theme-neutral.js +++ b/packages/mermaid/src/themes/theme-neutral.js @@ -303,6 +303,20 @@ class Theme { '#EEE,#6BB8E4,#8ACB88,#C7ACD6,#E8DCC2,#FFB2A8,#FFF380,#7E8D91,#FFD8B1,#FAF3E0', }; + /* radar */ + this.radar = { + axisColor: this.radar?.axisColor || this.lineColor, + axisStrokeWidth: this.radar?.axisStrokeWidth || 2, + axisLabelFontSize: this.radar?.axisLabelFontSize || 12, + curveOpacity: this.radar?.curveOpacity || 0.5, + curveStrokeWidth: this.radar?.curveStrokeWidth || 2, + graticuleColor: this.radar?.graticuleColor || '#DEDEDE', + graticuleStrokeWidth: this.radar?.graticuleStrokeWidth || 1, + graticuleOpacity: this.radar?.graticuleOpacity || 0.3, + legendBoxSize: this.radar?.legendBoxSize || 12, + legendFontSize: this.radar?.legendFontSize || 12, + }; + /* requirement-diagram */ this.requirementBackground = this.requirementBackground || this.primaryColor; this.requirementBorderColor = this.requirementBorderColor || this.primaryBorderColor; From 5e97b2f764e94d3268327d52f77f5d0b26a5992f Mon Sep 17 00:00:00 2001 From: Thomas Di Cizerone Date: Sun, 16 Mar 2025 18:17:07 +0100 Subject: [PATCH 04/17] =?UTF-8?q?=E2=9A=97=EF=B8=8F=20Add=20Cypress=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cypress/integration/rendering/radar.spec.js | 79 +++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 cypress/integration/rendering/radar.spec.js diff --git a/cypress/integration/rendering/radar.spec.js b/cypress/integration/rendering/radar.spec.js new file mode 100644 index 000000000..b0bc3f6e0 --- /dev/null +++ b/cypress/integration/rendering/radar.spec.js @@ -0,0 +1,79 @@ +import { imgSnapshotTest } from '../../helpers/util'; + +describe('radar structure', () => { + it('should render a simple radar diagram', () => { + imgSnapshotTest( + `radar-beta + title Best Radar Ever + axis A, B, C + curve c1{1, 2, 3} + ` + ); + }); + + it('should render a radar diagram with multiple curves', () => { + imgSnapshotTest( + `radar-beta + title Best Radar Ever + axis A, B, C + curve c1{1, 2, 3} + curve c2{2, 3, 1} + ` + ); + }); + + it('should render a complex radar diagram', () => { + imgSnapshotTest( + `radar-beta + title My favorite ninjas + axis Agility, Speed, Strength + axis Stam["Stamina"] , Intel["Intelligence"] + + curve Ninja1["Naruto Uzumaki"]{ + Agility 2, Speed 2, + Strength 3, Stam 5, + Intel 0 + } + curve Ninja2["Sasuke"]{2, 3, 4, 1, 5} + curve Ninja3 {3, 2, 1, 5, 4} + + showLegend true + ticks 3 + max 8 + min 0 + graticule polygon + ` + ); + cy.get('svg').should((svg) => { + expect(svg).to.have.length(1); + }); + }); + + it('should render radar diagram with config override', () => { + imgSnapshotTest( + `radar-beta + title Best Radar Ever + axis A,B,C + curve mycurve{1,2,3}`, + { radar: { marginTop: 100, axisScaleFactor: 0.5 } } + ); + }); + + it('should parse radar diagram with theme override', () => { + imgSnapshotTest( + `radar-beta + axis A,B,C + curve mycurve{1,2,3}`, + { theme: 'base', themeVariables: { fontSize: 80, cScale0: '#FF0000' } } + ); + }); + + it('should handle radar diagram with radar style override', () => { + imgSnapshotTest( + `radar-beta + axis A,B,C + curve mycurve{1,2,3}`, + { theme: 'base', themeVariables: { radar: { axisColor: '#FF0000' } } } + ); + }); +}); From a7f2c0bc344d0881c8486282bafd3a12856b481d Mon Sep 17 00:00:00 2001 From: Thomas Di Cizerone Date: Sun, 16 Mar 2025 18:18:18 +0100 Subject: [PATCH 05/17] =?UTF-8?q?=F0=9F=96=BC=EF=B8=8F=20Add=20Radar=20Dem?= =?UTF-8?q?o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- demos/index.html | 3 ++ demos/radar.html | 136 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 demos/radar.html diff --git a/demos/index.html b/demos/index.html index 07b51a313..d615bf347 100644 --- a/demos/index.html +++ b/demos/index.html @@ -91,6 +91,9 @@
  • Architecture

  • +
  • +

    Radar

    +
  • diff --git a/demos/radar.html b/demos/radar.html new file mode 100644 index 000000000..6e9f8df82 --- /dev/null +++ b/demos/radar.html @@ -0,0 +1,136 @@ + + + + + + Mermaid Quick Test Page + + + + + +

    Radar diagram demo

    + +
    +
    +      radar-beta 
    +        title My favorite ninjas
    +        axis Agility, Speed, Strength
    +        axis Stam["Stamina"] , Intel["Intelligence"]
    +      
    +        curve Ninja1["Naruto"]{
    +            Agility 2, Speed 2,
    +            Strength 3, Stam 5,
    +            Intel 0
    +        }
    +        curve Ninja2["Sasuke"]{2, 3, 4, 1, 5}
    +        curve Ninja3["Ninja"] {3, 2, 1, 5, 4}
    +      
    +        showLegend true
    +        ticks 3
    +        max 8
    +        min 0
    +        graticule circle
    +    
    + +
    +      ---
    +      config:
    +        radar:
    +          axisScaleFactor: 0.25
    +          axisLabelFactor: 0.95
    +      ---
    +      radar-beta 
    +        title DevOps Radar
    +        axis f["Feature Velocity"], s["Stability"]
    +        axis r["Resilience"], e["Efficiency"]
    +        axis c["Cost"], d["DevSecOps"]
    +      
    +        curve app1["App1"]{
    +          f 5, s 4.5, r 3.8, d 4.2, e 4.5, c 3.5
    +        }
    +        curve app2["App2"]{4, 3, 4, 3, 3, 4}, app3["App3"]{3, 2, 4, 3, 2, 3}
    +        curve app4["App4"]{2, 1, 3.2, 2.5, 1, 2}
    +      
    +        showLegend true
    +        ticks 3
    +        max 5
    +        graticule polygon
    +    
    + +
    +      %%{init: {'theme': 'forest'} }%%
    +      radar-beta 
    +        title Forest theme
    +        axis Agility, Speed, Strength
    +        axis Stam["Stamina"] , Intel["Intelligence"]
    +      
    +        curve Ninja1["Naruto"]{
    +            Agility 2, Speed 2,
    +            Strength 3, Stam 5,
    +            Intel 0
    +        }
    +        curve Ninja2["Sasuke"]{2, 3, 4, 1, 5}
    +        curve Ninja3["Ninja"] {3, 2, 1, 5, 4}
    +    
    + +
    +      %%{init: {'theme': 'dark'} }%%
    +      radar-beta 
    +        title Dark theme
    +        axis Agility, Speed, Strength
    +        axis Stam["Stamina"] , Intel["Intelligence"]
    +      
    +        curve Ninja1["Naruto"]{
    +            Agility 2, Speed 2,
    +            Strength 3, Stam 5,
    +            Intel 0
    +        }
    +        curve Ninja2["Sasuke"]{2, 3, 4, 1, 5}
    +        curve Ninja3["Ninja"] {3, 2, 1, 5, 4}
    +    
    +
    +      %%{init: {'theme': 'base', 'themeVariables': {'cScale0': '#ff0000', 'cScale1': '#00ff00', 'cScale2': '#0000ff'}} }%%
    +      radar-beta 
    +        title Custom colors
    +        axis Agility, Speed, Strength
    +        axis Stam["Stamina"] , Intel["Intelligence"]
    +
    +        curve Ninja1["Naruto"]{
    +            Agility 2, Speed 2,
    +            Strength 3, Stam 5,
    +            Intel 0
    +        }
    +        curve Ninja2["Sasuke"]{2, 3, 4, 1, 5}
    +        curve Ninja3["Ninja"] {3, 2, 1, 5, 4}
    +    
    +
    + + + + + From 12c120368d16f9b69d00fa44fb6bf20d887b60e1 Mon Sep 17 00:00:00 2001 From: Thomas Di Cizerone Date: Sun, 16 Mar 2025 18:19:09 +0100 Subject: [PATCH 06/17] =?UTF-8?q?=F0=9F=93=9A=20Add=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mermaid/src/docs/.vitepress/config.ts | 1 + .../src/docs/community/contributing.md | 16 ++ packages/mermaid/src/docs/syntax/radar.md | 198 ++++++++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 packages/mermaid/src/docs/syntax/radar.md diff --git a/packages/mermaid/src/docs/.vitepress/config.ts b/packages/mermaid/src/docs/.vitepress/config.ts index f8f740177..d3f4a9aee 100644 --- a/packages/mermaid/src/docs/.vitepress/config.ts +++ b/packages/mermaid/src/docs/.vitepress/config.ts @@ -159,6 +159,7 @@ function sidebarSyntax() { { text: 'Packet 🔥', link: '/syntax/packet' }, { text: 'Kanban 🔥', link: '/syntax/kanban' }, { text: 'Architecture 🔥', link: '/syntax/architecture' }, + { text: 'Radar 🔥', link: '/syntax/radar' }, { text: 'Other Examples', link: '/syntax/examples' }, ], }, diff --git a/packages/mermaid/src/docs/community/contributing.md b/packages/mermaid/src/docs/community/contributing.md index 4cd649563..a962907df 100644 --- a/packages/mermaid/src/docs/community/contributing.md +++ b/packages/mermaid/src/docs/community/contributing.md @@ -240,6 +240,22 @@ Code is the heart of every software project. We strive to make it better. Who if The core of Mermaid is located under `packages/mermaid/src`. +### Building Mermaid Locally + +**Host** + +```bash +pnpm run build +``` + +**Docker** + +```bash +./run build +``` + +This will build the Mermaid library and the documentation site. + ### Running Mermaid Locally **Host** diff --git a/packages/mermaid/src/docs/syntax/radar.md b/packages/mermaid/src/docs/syntax/radar.md new file mode 100644 index 000000000..50293770c --- /dev/null +++ b/packages/mermaid/src/docs/syntax/radar.md @@ -0,0 +1,198 @@ +# Radar Diagram (v+) + +## Introduction + +A radar diagram is a simple way to plot low-dimensional data in a circular format. + +It is also known as a **radar chart**, **spider chart**, **star chart**, **cobweb chart**, **polar chart**, or **Kiviat diagram**. + +## Usage + +This diagram type is particularly useful for developers, data scientists, and engineers who require a clear and concise way to represent data in a circular format. + +It is commonly used to graphically summarize and compare the performance of multiple entities across multiple dimensions. + +## Syntax + +```md +radar-beta +axis A, B, C, D, E +curve c1{1,2,3,4,5} +curve c2{5,4,3,2,1} +... More Fields ... +``` + +## Examples + +```mermaid-example +--- +title: "Grades" +--- +radar-beta + axis m["Math"], s["Science"], e["English"] + axis h["History"], g["Geography"], a["Art"] + curve a["Alice"]{85, 90, 80, 70, 75, 90} + curve b["Bob"]{70, 75, 85, 80, 90, 85} + + max 100 + min 0 +``` + +```mermaid-example +radar-beta + title Restaurant Comparison + axis food["Food Quality"], service["Service"], price["Price"] + axis ambiance["Ambiance"], + + curve a["Restaurant A"]{4, 3, 2, 4} + curve b["Restaurant B"]{3, 4, 3, 3} + curve c["Restaurant C"]{2, 3, 4, 2} + curve d["Restaurant D"]{2, 2, 4, 3} + + graticule polygon + max 5 + +``` + +## Details of Syntax + +### Title + +`title`: The title is an optional field that allows to render a title at the top of the radar diagram. + +``` +radar-beta + title Title of the Radar Diagram + ... +``` + +### Axis + +`axis`: The axis keyword is used to define the axes of the radar diagram. + +Each axis is represented by an ID and an optional label. + +Multiple axes can be defined in a single line. + +``` +radar-beta + axis id1["Label1"] + axis id2["Label2"], id3["Label3"] + ... +``` + +### Curve + +`curve`: The curve keyword is used to define the data points for a curve in the radar diagram. + +Each curve is represented by an ID, an optional label, and a list of values. + +Values can be defined by a list of numbers or a list of key-value pairs. If key-value pairs are used, the key represents the axis ID and the value represents the data point. Else, the data points are assumed to be in the order of the axes defined. + +Multiple curves can be defined in a single line. + +``` +radar-beta + axis axis1, axis2, axis3 + curve id1["Label1"]{1, 2, 3} + curve id2["Label2"]{4, 5, 6}, id3{7, 8, 9} + curve id4{ axis3: 30, axis1: 20, axis2: 10 } + ... +``` + +### Options + +- `showLegend`: The showLegend keyword is used to show or hide the legend in the radar diagram. The legend is shown by default. +- `max`: The maximum value for the radar diagram. This is used to scale the radar diagram. If not provided, the maximum value is calculated from the data points. +- `min`: The minimum value for the radar diagram. This is used to scale the radar diagram. If not provided, the minimum value is `0`. +- `graticule`: The graticule keyword is used to define the type of graticule to be rendered in the radar diagram. The graticule can be `circle` or `polygon`. If not provided, the default graticule is `circle`. +- `ticks`: The ticks keyword is used to define the number of ticks on the graticule. It is the number of concentric circles or polygons drawn to indicate the scale of the radar diagram. If not provided, the default number of ticks is `5`. + +``` +radar-beta + ... + showLegend true + max 100 + min 0 + graticule circle + ticks 5 + ... +``` + +## Configuration + +Please refer to the [configuration](/config/schema-docs/config-defs-radar-diagram-config.html) guide for details. + +| Parameter | Description | Default Value | +| --------------- | ---------------------------------------- | ------------- | +| width | Width of the radar diagram | `600` | +| height | Height of the radar diagram | `600` | +| marginTop | Top margin of the radar diagram | `50` | +| marginBottom | Bottom margin of the radar diagram | `50` | +| marginLeft | Left margin of the radar diagram | `50` | +| marginRight | Right margin of the radar diagram | `50` | +| axisScaleFactor | Scale factor for the axis | `1` | +| axisLabelFactor | Factor to adjust the axis label position | `1.05` | +| curveTension | Tension for the rounded curves | `0.17` | + +## Theme Variables + +### Global Theme Variables + +```note +The default values for these variables depend on the theme used. To override the default values, set the desired values in the themeVariables section of the configuration: +%%{init: {"themeVariables": {"cScale0": "#FF0000", "cScale1": "#00FF00"}} }%% +``` + +Radar charts support the color scales `cScale${i}` where `i` is a number from `0` to the theme's maximum number of colors in its color scale. Usually, the maximum number of colors is `12`. + +| Property | Description | +| ---------- | ------------------------------ | +| fontSize | Font size of the title | +| titleColor | Color of the title | +| cScale${i} | Color scale for the i-th curve | + +### Radar Style Options + +```note +Specific variables for radar resides inside the `radar` key. To set the radar style options, use this syntax. +%%{init: {"themeVariables": {"radar": {"axisColor": "#FF0000"}} } }%% +``` + +| Property | Description | Default Value | +| -------------------- | ---------------------------- | ------------- | +| axisColor | Color of the axis lines | `black` | +| axisStrokeWidth | Width of the axis lines | `1` | +| axisLabelFontSize | Font size of the axis labels | `12px` | +| curveOpacity | Opacity of the curves | `0.7` | +| curveStrokeWidth | Width of the curves | `2` | +| graticuleColor | Color of the graticule | `black` | +| graticuleOpacity | Opacity of the graticule | `0.5` | +| graticuleStrokeWidth | Width of the graticule | `1` | +| legendBoxSize | Size of the legend box | `10` | +| legendFontSize | Font size of the legend | `14px` | + +## Example on config and theme + +```mermaid-example +--- +config: + radar: + axisScaleFactor: 0.25 + curveTension: 0.1 + theme: base + themeVariables: + cScale0: "#FF0000" + cScale1: "#00FF00" + cScale2: "#0000FF" + radar: + curveOpacity: 0 +--- +radar-beta + axis A, B, C, D, E + curve c1{1,2,3,4,5} + curve c2{5,4,3,2,1} + curve c3{3,3,3,3,3} +``` + + From 0f5125b5e30d1a708fa18c91b50cd14d25428289 Mon Sep 17 00:00:00 2001 From: Thomas Di Cizerone Date: Sun, 16 Mar 2025 18:34:00 +0100 Subject: [PATCH 07/17] =?UTF-8?q?=F0=9F=96=BC=EF=B8=8F=20Add=20Radar=20Dem?= =?UTF-8?q?o=20with=20custom=20opacity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- demos/radar.html | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/demos/radar.html b/demos/radar.html index 6e9f8df82..16d03abfe 100644 --- a/demos/radar.html +++ b/demos/radar.html @@ -113,6 +113,27 @@ curve Ninja3["Ninja"] {3, 2, 1, 5, 4} +
    +      ---
    +      config:
    +        radar:
    +          axisScaleFactor: 0.25
    +          curveTension: 0.1
    +        theme: base
    +        themeVariables:
    +          cScale0: "#FF0000"
    +          cScale1: "#00FF00"
    +          cScale2: "#0000FF"
    +          radar:
    +            curveOpacity: 0
    +      ---
    +      radar-beta
    +        title Custom colors, axisScaleFactor, curveTension, opacity
    +        axis A, B, C, D, E
    +        curve c1{1,2,3,4,5}
    +        curve c2{5,4,3,2,1}
    +        curve c3{3,3,3,3,3}
    +