mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-09-28 03:39:38 +02:00
📊 Add radar chart
This commit is contained in:
@@ -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".
|
||||
|
@@ -255,8 +255,12 @@ const config: RequiredDeep<MermaidConfig> = {
|
||||
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])) {
|
||||
|
@@ -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
|
||||
);
|
||||
};
|
||||
|
128
packages/mermaid/src/diagrams/radar/db.ts
Normal file
128
packages/mermaid/src/diagrams/radar/db.ts
Normal file
@@ -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<RadarDiagramConfig> = DEFAULT_CONFIG.radar;
|
||||
|
||||
const getConfig = (): Required<RadarDiagramConfig> => {
|
||||
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<string, Option>
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
22
packages/mermaid/src/diagrams/radar/detector.ts
Normal file
22
packages/mermaid/src/diagrams/radar/detector.ts
Normal file
@@ -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,
|
||||
};
|
12
packages/mermaid/src/diagrams/radar/diagram.ts
Normal file
12
packages/mermaid/src/diagrams/radar/diagram.ts
Normal file
@@ -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,
|
||||
};
|
23
packages/mermaid/src/diagrams/radar/parser.ts
Normal file
23
packages/mermaid/src/diagrams/radar/parser.ts
Normal file
@@ -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<void> => {
|
||||
const ast: Radar = await parse('radar', input);
|
||||
log.debug(ast);
|
||||
populate(ast);
|
||||
},
|
||||
};
|
167
packages/mermaid/src/diagrams/radar/radar.spec.ts
Normal file
167
packages/mermaid/src/diagrams/radar/radar.spec.ts
Normal file
@@ -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
|
||||
});
|
||||
});
|
226
packages/mermaid/src/diagrams/radar/renderer.ts
Normal file
226
packages/mermaid/src/diagrams/radar/renderer.ts
Normal file
@@ -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<RadarDiagramConfig>): 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<RadarDiagramConfig>
|
||||
) => {
|
||||
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<RadarDiagramConfig>
|
||||
) {
|
||||
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<RadarDiagramConfig>
|
||||
) {
|
||||
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);
|
||||
});
|
||||
}
|
71
packages/mermaid/src/diagrams/radar/styles.ts
Normal file
71
packages/mermaid/src/diagrams/radar/styles.ts
Normal file
@@ -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<typeof getThemeVariables>,
|
||||
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;
|
47
packages/mermaid/src/diagrams/radar/types.ts
Normal file
47
packages/mermaid/src/diagrams/radar/types.ts
Normal file
@@ -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<RadarDiagramConfig> {
|
||||
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;
|
||||
}
|
@@ -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', () => {
|
||||
|
@@ -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: |
|
||||
|
@@ -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');
|
||||
|
Reference in New Issue
Block a user