[IMP] web: line chart on top of stacked bar chart

When stacking a bar chart, you often want to see the resulting sum of the
different groups (esp. if there are negative values). For example, when
displaying a stacked chart of invoices vs credit notes, you usually also
want to know the net total.

This is done only on stacked bar charts that have single column per
x-axis label and with more than one level of groupby.

closes odoo/odoo#114132

Task-id: 3193260
Signed-off-by: Mathieu Duckerts-Antoine <dam@odoo.com>
This commit is contained in:
Joseph Caburnay
2023-03-14 12:23:57 +01:00
parent 404a083574
commit b03cfce033
3 changed files with 119 additions and 18 deletions
@@ -1,12 +1,13 @@
/** @odoo-module **/
import { sortBy } from "@web/core/utils/arrays";
import { sortBy, groupBy } from "@web/core/utils/arrays";
import { KeepLast, Race } from "@web/core/utils/concurrency";
import { rankInterval } from "@web/search/utils/dates";
import { getGroupBy } from "@web/search/utils/group_by";
import { GROUPABLE_TYPES } from "@web/search/utils/misc";
import { Model } from "@web/views/model";
import { computeReportMeasures, processMeasure } from "@web/views/utils";
import { sprintf } from "@web/core/utils/strings";
export const SEP = " / ";
@@ -98,6 +99,8 @@ export class GraphModel extends Model {
this.metaData = params;
this.data = null;
this.searchParams = null;
// This dataset will be added as a line plot on top of stacked bar chart.
this.lineOverlayDataset = null;
}
//--------------------------------------------------------------------------
@@ -285,6 +288,40 @@ export class GraphModel extends Model {
return { datasets, labels };
}
_getLabel(description) {
if (!description) {
return this.env._t("Sum");
} else {
return sprintf(this.env._t("Sum (%s)"), description);
}
}
_getLineOverlayDataset() {
const { domains, stacked } = this.metaData;
const data = this.data;
let lineOverlayDataset = null;
if (stacked) {
const stacks = groupBy(data.datasets, (dataset) => dataset.originIndex);
if (Object.keys(stacks).length == 1) {
const [[originIndex, datasets]] = Object.entries(stacks);
if (datasets.length > 1) {
const data = [];
for (const dataset of datasets) {
for (let i = 0; i < dataset.data.length; i++) {
data[i] = (data[i] || 0) + dataset.data[i];
}
}
lineOverlayDataset = {
label: this._getLabel(domains[originIndex].description),
data,
trueLabels: datasets[0].trueLabels,
};
}
}
}
return lineOverlayDataset;
}
/**
* Determines the dataset to which the data point belongs.
* @protected
@@ -519,5 +556,9 @@ export class GraphModel extends Model {
async _prepareData() {
const processedDataPoints = this._getProcessedDataPoints();
this.data = this._getData(processedDataPoints);
this.lineOverlayDataset = null;
if (this.metaData.mode === "bar") {
this.lineOverlayDataset = this._getLineOverlayDataset();
}
}
}
@@ -205,7 +205,7 @@ export class GraphRenderer extends Component {
getBarChartData() {
// style data
const { domains, stacked } = this.model.metaData;
const data = this.model.data;
const { data, lineOverlayDataset } = this.model;
for (let index = 0; index < data.datasets.length; ++index) {
const dataset = data.datasets[index];
// used when stacked
@@ -215,6 +215,29 @@ export class GraphRenderer extends Component {
// set dataset color
dataset.backgroundColor = getColor(index, this.cookies.current.color_scheme);
}
if (lineOverlayDataset) {
// Mutate the lineOverlayDataset to include the config on how it will be displayed.
const color = this.cookies.current.color_scheme === "dark" ? "ffffff" : "000000";
Object.assign(lineOverlayDataset, {
type: "line",
order: -1,
tension: 0,
fill: false,
borderWidth: 2,
borderDash: [5, 4],
borderColor: hexToRGBA(color, LINE_FILL_TRANSPARENCY),
pointHitRadius: 5,
pointRadius: 5,
pointHoverRadius: 10,
backgroundColor: hexToRGBA(color, LINE_FILL_TRANSPARENCY),
});
// We're not mutating the original datasets (`this.model.data.datasets`)
// because some part of the code depends on it.
return {
...data,
datasets: [...data.datasets, lineOverlayDataset],
};
}
return data;
}
@@ -478,7 +501,8 @@ export class GraphRenderer extends Component {
const items = [];
for (const item of sortedDataPoints) {
const index = item.index;
const dataset = data.datasets[item.datasetIndex];
// If `datasetIndex` is not found in the `datasets`, then it refers to the `lineOverlayDataset`.
const dataset = data.datasets[item.datasetIndex] || this.model.lineOverlayDataset;
let label = dataset.trueLabels[index];
let value = this.formatValue(dataset.data[index], allIntegers);
let boxColor;
@@ -404,9 +404,15 @@ QUnit.module("Views", (hooks) => {
data: [4, 0],
label: "xpad",
},
{
backgroundColor: "rgba(0,0,0,0.4)",
borderColor: "rgba(0,0,0,0.4)",
data: [5, 3],
label: "Sum",
},
]
);
checkLegend(assert, graph, ["xphone", "xpad"]);
checkLegend(assert, graph, ["xphone", "xpad", "Sum"]);
checkTooltip(assert, graph, { lines: [{ label: "false / xphone", value: "1" }] }, 0, 0);
checkTooltip(assert, graph, { lines: [{ label: "true / xphone", value: "3" }] }, 1, 0);
checkTooltip(assert, graph, { lines: [{ label: "false / xpad", value: "4" }] }, 0, 1);
@@ -2206,13 +2212,18 @@ QUnit.module("Views", (hooks) => {
});
assert.containsNone(target, ".o_view_nocontent");
assert.containsOnce(target, ".o_graph_canvas_container");
checkDatasets(assert, graph, ["backgroundColor", "borderColor", "data", "label", "stack"], {
backgroundColor: ["#1f77b4"],
borderColor: getBorderWhite(),
data: [2],
label: "",
stack: undefined,
});
checkDatasets(
assert,
graph,
["backgroundColor", "borderColor", "data", "label", "stack"],
{
backgroundColor: ["#1f77b4"],
borderColor: getBorderWhite(),
data: [2],
label: "",
stack: undefined,
}
);
}
);
@@ -2394,7 +2405,7 @@ QUnit.module("Views", (hooks) => {
});
checkLabels(assert, graph, ["xphone", "xpad"]);
checkLegend(assert, graph, ["false / Undefined", "true / Undefined", "true / red"]);
checkLegend(assert, graph, ["false / Undefined", "true / Undefined", "true / red", "Sum"]);
await selectMode(target, "line");
@@ -3455,8 +3466,12 @@ QUnit.module("Views", (hooks) => {
`,
});
checkLegend(assert, graph, ["false", "true"], "measure should be by count");
checkDatasets(assert, graph, "data", [{ data: [1, 1, 3] }, { data: [3, 0, 0] }]);
checkLegend(assert, graph, ["false", "true", "Sum"], "measure should be by count");
checkDatasets(assert, graph, "data", [
{ data: [1, 1, 3] },
{ data: [3, 0, 0] },
{ data: [4, 1, 3] },
]);
await click(target, "button.fa-sort-amount-asc");
assert.hasClass(
@@ -3464,7 +3479,11 @@ QUnit.module("Views", (hooks) => {
"active",
"ascending order should be applied by default"
);
checkDatasets(assert, graph, "data", [{ data: [1, 3, 1] }, { data: [0, 0, 3] }]);
checkDatasets(assert, graph, "data", [
{ data: [1, 3, 1] },
{ data: [0, 0, 3] },
{ data: [1, 3, 4] },
]);
await click(target, "button.fa-sort-amount-desc");
assert.hasClass(
@@ -3472,7 +3491,11 @@ QUnit.module("Views", (hooks) => {
"active",
"ascending order button should be active"
);
checkDatasets(assert, graph, "data", [{ data: [1, 3, 1] }, { data: [3, 0, 0] }]);
checkDatasets(assert, graph, "data", [
{ data: [1, 3, 1] },
{ data: [3, 0, 0] },
{ data: [4, 3, 1] },
]);
// again click on descending button to deactivate order button
await click(target, "button.fa-sort-amount-desc");
@@ -3481,7 +3504,11 @@ QUnit.module("Views", (hooks) => {
"active",
"descending order button should not be active"
);
checkDatasets(assert, graph, "data", [{ data: [1, 1, 3] }, { data: [3, 0, 0] }]);
checkDatasets(assert, graph, "data", [
{ data: [1, 1, 3] },
{ data: [3, 0, 0] },
{ data: [4, 1, 3] },
]);
});
QUnit.test("graph view sort by measure for multiple grouped data", async function (assert) {
@@ -3511,11 +3538,17 @@ QUnit.module("Views", (hooks) => {
`,
});
checkLegend(assert, graph, ["xphone", "xpad", "zphone"], "measure should be by count");
checkLegend(
assert,
graph,
["xphone", "xpad", "zphone", "Sum"],
"measure should be by count"
);
checkDatasets(assert, graph, "data", [
{ data: [1, 0, 0, 0] },
{ data: [1, 2, 1, 2] },
{ data: [0, 1, 0, 0] },
{ data: [2, 3, 1, 2] },
]);
await click(target, "button.fa-sort-amount-asc");
@@ -3528,6 +3561,7 @@ QUnit.module("Views", (hooks) => {
{ data: [1, 1, 2, 2] },
{ data: [0, 1, 0, 0] },
{ data: [0, 0, 0, 1] },
{ data: [1, 2, 2, 3] },
]);
await click(target, "button.fa-sort-amount-desc");
@@ -3540,6 +3574,7 @@ QUnit.module("Views", (hooks) => {
{ data: [1, 0, 0, 0] },
{ data: [2, 1, 2, 1] },
{ data: [0, 1, 0, 0] },
{ data: [3, 2, 2, 1] },
]);
// again click on descending button to deactivate order button
@@ -3553,6 +3588,7 @@ QUnit.module("Views", (hooks) => {
{ data: [1, 0, 0, 0] },
{ data: [1, 2, 1, 2] },
{ data: [0, 1, 0, 0] },
{ data: [2, 3, 1, 2] },
]);
});