Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Stack multiple bar plot by default and properly render bar plots if there is negative values #5274

Open
wants to merge 10 commits into
base: xychart
Choose a base branch
from
2 changes: 1 addition & 1 deletion .github/workflows/link-checker.yml
Expand Up @@ -36,7 +36,7 @@ jobs:
restore-keys: cache-lychee-

- name: Link Checker
uses: lycheeverse/lychee-action@v1.9.1
uses: lycheeverse/lychee-action@v1.9.3
with:
args: >-
--config .github/lychee.toml
Expand Down
1 change: 1 addition & 0 deletions .npmrc
@@ -1,3 +1,4 @@
registry=https://registry.npmjs.org
auto-install-peers=true
strict-peer-dependencies=false
package-import-method=clone-or-copy
3 changes: 2 additions & 1 deletion .prettierignore
@@ -1,6 +1,7 @@
dist
cypress/platform/xss3.html
.cache
.pnpm-store
coverage
# Autogenerated by PNPM
pnpm-lock.yaml
Expand All @@ -12,4 +13,4 @@ stats
packages/mermaid/src/config.type.ts
# Ignore the files creates in /demos/dev except for example.html
demos/dev/**
!/demos/dev/example.html
!/demos/dev/example.html
18 changes: 18 additions & 0 deletions demos/xychart.html
Expand Up @@ -23,6 +23,24 @@ <h1>XY Charts demos</h1>
bar [5000, 6000, 7500, 8200, 9500, 10500, 11000, 10200, 9200, 8500, 7000, 6000]
line [5000, 6000, 7500, 8200, 9500, 10500, 11000, 10200, 9200, 8500, 7000, 6000]
</pre>
<h1>XY Charts demos with negative and stacked</h1>
<pre class="mermaid">
xychart-beta
title "Sales Revenue (in $)"
x-axis [jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec]
bar [5000, 6000, 7500, 8200, -500, 10500, 11000, 10200, 9200, 8500, 7000, 6000]
bar [3000, 2000, 500, 1200, -3400, 3500, 1000, 8200, 2300, 9900, 9000, 3000]
line [5000, 6000, 7500, 8200, -200, 10500, 11000, 10200, 9200, 8500, 7000, 6000]
</pre>
<h1>XY Charts demos with negative and stacked horizontal</h1>
<pre class="mermaid">
xychart-beta horizontal
title "Sales Revenue (in $)"
x-axis [jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec]
bar [5000, 6000, 7500, 8200, -500, 10500, 11000, 10200, 9200, 8500, 7000, 6000]
bar [3000, 2000, 500, 1200, -3400, 3500, 1000, 8200, 2300, 9900, 9000, 3000]
line [5000, 6000, 7500, 8200, -200, 10500, 11000, 10200, 9200, 8500, 7000, 6000]
</pre>
<hr />
<h1>XY Charts horizontal</h1>
<pre class="mermaid">
Expand Down
4 changes: 2 additions & 2 deletions docs/syntax/c4.md
Expand Up @@ -320,7 +320,7 @@ UpdateRelStyle(customerA, bankA, $offsetY="60")
Person(customer, Customer, "A customer of the bank, with personal bank accounts", $tags="v1.0")

Container_Boundary(c1, "Internet Banking") {
Container(spa, "Single-Page App", "JavaScript, Angular", "Provides all the Internet banking functionality to cutomers via their web browser")
Container(spa, "Single-Page App", "JavaScript, Angular", "Provides all the Internet banking functionality to customers via their web browser")
Container_Ext(mobile_app, "Mobile App", "C#, Xamarin", "Provides a limited subset of the Internet banking functionality to customers via their mobile device")
Container(web_app, "Web Application", "Java, Spring MVC", "Delivers the static content and the Internet banking SPA")
ContainerDb(database, "Database", "SQL Database", "Stores user registration information, hashed auth credentials, access logs, etc.")
Expand Down Expand Up @@ -360,7 +360,7 @@ UpdateRelStyle(customerA, bankA, $offsetY="60")
Person(customer, Customer, "A customer of the bank, with personal bank accounts", $tags="v1.0")

Container_Boundary(c1, "Internet Banking") {
Container(spa, "Single-Page App", "JavaScript, Angular", "Provides all the Internet banking functionality to cutomers via their web browser")
Container(spa, "Single-Page App", "JavaScript, Angular", "Provides all the Internet banking functionality to customers via their web browser")
Container_Ext(mobile_app, "Mobile App", "C#, Xamarin", "Provides a limited subset of the Internet banking functionality to customers via their mobile device")
Container(web_app, "Web Application", "Java, Spring MVC", "Delivers the static content and the Internet banking SPA")
ContainerDb(database, "Database", "SQL Database", "Stores user registration information, hashed auth credentials, access logs, etc.")
Expand Down
4 changes: 2 additions & 2 deletions package.json
Expand Up @@ -4,7 +4,7 @@
"version": "10.2.4",
"description": "Markdownish syntax for generating flowcharts, sequence diagrams, class diagrams, gantt charts and git graphs.",
"type": "module",
"packageManager": "pnpm@8.14.1",
"packageManager": "pnpm@8.15.1",
"keywords": [
"diagram",
"markdown",
Expand Down Expand Up @@ -87,7 +87,7 @@
"cors": "^2.8.5",
"cypress": "^12.17.4",
"cypress-image-snapshot": "^4.0.1",
"esbuild": "^0.19.0",
"esbuild": "^0.20.0",
"eslint": "^8.47.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-cypress": "^2.13.2",
Expand Down
Expand Up @@ -21,6 +21,10 @@ export class BandAxis extends BaseAxis {
this.scale = scaleBand().domain(this.categories).range(this.getRange());
}

isZeroBasedDomain(): boolean {
return true;
}

setRange(range: [number, number]): void {
super.setRange(range);
}
Expand Down
Expand Up @@ -54,6 +54,8 @@ export abstract class BaseAxis implements Axis {
this.setRange(this.range);
}

abstract isZeroBasedDomain(): boolean;

abstract getScaleValue(value: number | string): number;

abstract recalculateScale(): void;
Expand Down
Expand Up @@ -19,6 +19,7 @@ export interface Axis extends ChartComponent {
getTickDistance(): number;
recalculateOuterPaddingToDrawBar(): void;
setRange(range: [number, number]): void;
isZeroBasedDomain(): boolean;
}

export function getAxis(
Expand Down
Expand Up @@ -20,6 +20,9 @@ export class LinearAxis extends BaseAxis {
this.scale = scaleLinear().domain(this.domain).range(this.getRange());
}

isZeroBasedDomain(): boolean {
return this.domain[0] < 0;
}
getTickValues(): (string | number)[] {
return this.scale.ticks();
}
Expand Down
Expand Up @@ -3,7 +3,7 @@ import type { Axis } from '../axis/index.js';

export class BarPlot {
constructor(
private barData: BarPlotData[],
private barDataArr: BarPlotData[],
private boundingRect: BoundingRect,
private xAxis: Axis,
private yAxis: Axis,
Expand All @@ -12,85 +12,83 @@ export class BarPlot {
) {}

getDrawableElement(): DrawableElem[] {
const offset = new Array(this.barData[0].data.length).fill(0);
const enlarge = new Array(this.barData[0].data.length).fill(0);
return this.barData.map((barData, dataIndex) => {
const finalData: [number, number][] = barData.data.map((d) => [
this.xAxis.getScaleValue(d[0]),
this.yAxis.getScaleValue(d[1]),
]);
const result: DrawableElem[] = [];
this.barDataArr.reduce<{ positiveBase: number[]; negativeBase: number[] }>(
(acc, barData, dataIndex) => {
const barPaddingPercent = 0.05;

const barPaddingPercent = 0.05;
const barWidth =
Math.min(this.xAxis.getAxisOuterPadding() * 2, this.xAxis.getTickDistance()) *
(1 - barPaddingPercent);
const barWidthHalf = barWidth / 2;

const barWidth =
Math.min(this.xAxis.getAxisOuterPadding() * 2, this.xAxis.getTickDistance()) *
(1 - barPaddingPercent);
const barWidthHalf = barWidth / 2;

if (this.orientation === 'horizontal') {
return {
groupTexts: ['plot', `bar-plot-${this.plotIndex}-${dataIndex}`],
type: 'rect',
data: finalData.map((data, index) => {
const adjustForAxisOuterPadding = dataIndex > 0 ? this.yAxis.getAxisOuterPadding() : 0;
let x = offset[index] + this.boundingRect.x;
let width = data[1] - this.boundingRect.x - adjustForAxisOuterPadding;
if (enlarge[index] > 0) {
x -= enlarge[index];
width += enlarge[index];
enlarge[index] = 0;
offset[index] -= adjustForAxisOuterPadding;
}
offset[index] += width;
if (barData.data[index][1] === 0 && enlarge[index] === 0) {
enlarge[index] = width;
}
if (barData.data[index][1] === 0) {
width = 0;
}
return {
x,
y: data[0] - barWidthHalf,
height: barWidth,
width,
fill: barData.fill,
strokeWidth: 0,
strokeFill: barData.fill,
};
}),
};
}
return {
groupTexts: ['plot', `bar-plot-${this.plotIndex}-${dataIndex}`],
type: 'rect',
data: finalData.map((data, index) => {
const adjustForAxisOuterPadding = dataIndex > 0 ? this.yAxis.getAxisOuterPadding() : 0;
const y = data[1] - offset[index] + adjustForAxisOuterPadding;
let height =
this.boundingRect.y + this.boundingRect.height - data[1] - adjustForAxisOuterPadding;
if (enlarge[index] > 0) {
height += enlarge[index];
enlarge[index] = 0;
offset[index] -= adjustForAxisOuterPadding;
}
offset[index] += height;
if (barData.data[index][1] === 0 && enlarge[index] === 0) {
enlarge[index] = height;
}
if (barData.data[index][1] === 0) {
height = 0;
}
return {
x: data[0] - barWidthHalf,
y,
width: barWidth,
height,
fill: barData.fill,
strokeWidth: 0,
strokeFill: barData.fill,
};
}),
};
});
if (this.orientation === 'horizontal') {
result.push({
groupTexts: ['plot', `bar-plot-${this.plotIndex}-${dataIndex}`],
type: 'rect',
data: barData.data.map((data, i) => {
const scaledX = this.xAxis.getScaleValue(data[0]);
const scaledY = this.yAxis.getScaleValue(data[1]);
const basePoint = this.yAxis.isZeroBasedDomain()
? this.yAxis.getScaleValue(0)
: this.boundingRect.x;
const width = Math.abs(basePoint - scaledY);
let widthAdjusted = 0;
const isPositive = data[1] >= 0;
if (isPositive) {
widthAdjusted = acc.positiveBase[i] || 0;
acc.positiveBase[i] = widthAdjusted + width;
} else {
widthAdjusted = acc.negativeBase[i] || 0;
acc.negativeBase[i] = widthAdjusted + width;
}
return {
x: isPositive ? basePoint + widthAdjusted : basePoint - widthAdjusted - width,
y: scaledX - barWidthHalf,
height: barWidth,
width,
fill: barData.fill,
strokeWidth: 0,
strokeFill: barData.fill,
};
}),
});
} else {
result.push({
groupTexts: ['plot', `bar-plot-${this.plotIndex}-${dataIndex}`],
type: 'rect',
data: barData.data.map((data, i) => {
const scaledX = this.xAxis.getScaleValue(data[0]);
const scaledY = this.yAxis.getScaleValue(data[1]);
const basePoint = this.yAxis.isZeroBasedDomain()
? this.yAxis.getScaleValue(0)
: this.boundingRect.y + this.boundingRect.height;
const height = Math.abs(basePoint - scaledY);
let heightAdjusted = 0;
const isPositive = data[1] >= 0;
if (isPositive) {
heightAdjusted = acc.positiveBase[i] || 0;
acc.positiveBase[i] = heightAdjusted + height;
} else {
heightAdjusted = acc.negativeBase[i] || 0;
acc.negativeBase[i] = heightAdjusted + height;
}
return {
x: scaledX - barWidthHalf,
y: isPositive ? scaledY - heightAdjusted : basePoint + heightAdjusted,
width: barWidth,
height,
fill: barData.fill,
strokeWidth: 0,
strokeFill: barData.fill,
};
}),
});
}
return acc;
},
{ positiveBase: [], negativeBase: [] }
);
return result;
}
}
Expand Up @@ -66,16 +66,6 @@ export class BasePlot implements Plot {
) as BarPlotData[];

let plotIndex = 0;
if (linePlots.length) {
const linePlot = new LinePlot(
linePlots,
this.xAxis,
this.yAxis,
this.chartConfig.chartOrientation,
plotIndex
);
drawableElem.push(...linePlot.getDrawableElement());
}
if (barPlots.length) {
const barPlot = new BarPlot(
barPlots,
Expand All @@ -88,6 +78,16 @@ export class BasePlot implements Plot {
drawableElem.push(...barPlot.getDrawableElement());
plotIndex++;
}
if (linePlots.length) {
const linePlot = new LinePlot(
linePlots,
this.xAxis,
this.yAxis,
this.chartConfig.chartOrientation,
plotIndex
);
drawableElem.push(...linePlot.getDrawableElement());
}
return drawableElem;
}
}
Expand Down
29 changes: 19 additions & 10 deletions packages/mermaid/src/diagrams/xychart/xychartDb.ts
Expand Up @@ -28,6 +28,8 @@ let plotIndex = 0;

let tmpSVGGroup: Group;

let barPlotMaxData: number[] = [];

let xyChartConfig: XYChartConfig = getChartDefaultConfig();
let xyChartThemeConfig: XYChartThemeConfig = getChartDefaultThemeConfig();
let xyChartData: XYChartData = getChartDefaultData();
Expand Down Expand Up @@ -60,7 +62,7 @@ function getChartDefaultData(): XYChartData {
yAxis: {
type: 'linear',
title: '',
min: 0,
min: Infinity,
max: -Infinity,
},
xAxis: {
Expand Down Expand Up @@ -113,21 +115,27 @@ function setYAxisRangeData(min: number, max: number) {

// this function does not set `hasSetYAxis` as there can be multiple data so we should calculate the range accordingly
function setYAxisRangeFromPlotData(data: number[], plotType: PlotType) {
const sum = new Array(data.length).fill(0);
let minValue = 0;
let maxValue = -Infinity;
if (plotType === PlotType.BAR) {
dataSets.push(data);
for (let i = 0; i < data.length; i++) {
for (const entry of dataSets) {
sum[i] += entry[i];
}
let i = 0;
for (const d of data) {
barPlotMaxData[i] = (barPlotMaxData[i] || 0) + d;
maxValue = Math.max(...barPlotMaxData);
minValue = Math.min(...barPlotMaxData);
i++;
}
} else {
maxValue = Math.max(...data);
minValue = Math.min(...data);
}

const prevMinValue = isLinearAxisData(xyChartData.yAxis) ? xyChartData.yAxis.min : Infinity;
const prevMaxValue = isLinearAxisData(xyChartData.yAxis) ? xyChartData.yAxis.max : -Infinity;
xyChartData.yAxis = {
type: 'linear',
title: xyChartData.yAxis.title,
min: isLinearAxisData(xyChartData.yAxis) ? xyChartData.yAxis.min : Math.min(...sum),
max: Math.max(...sum),
min: Math.min(prevMinValue, minValue),
max: Math.max(prevMaxValue, maxValue),
};
}

Expand Down Expand Up @@ -207,6 +215,7 @@ function getChartConfig() {
const clear = function () {
commonClear();
plotIndex = 0;
barPlotMaxData = [];
xyChartConfig = getChartDefaultConfig();
xyChartData = getChartDefaultData();
xyChartThemeConfig = getChartDefaultThemeConfig();
Expand Down