From b03cfce033565ecd29ca80a3be73711749c4a185 Mon Sep 17 00:00:00 2001 From: Joseph Caburnay Date: Wed, 1 Mar 2023 13:10:51 +0000 Subject: [PATCH] [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 --- .../web/static/src/views/graph/graph_model.js | 43 +++++++++++- .../static/src/views/graph/graph_renderer.js | 28 +++++++- .../static/tests/views/graph_view_tests.js | 66 ++++++++++++++----- 3 files changed, 119 insertions(+), 18 deletions(-) diff --git a/addons/web/static/src/views/graph/graph_model.js b/addons/web/static/src/views/graph/graph_model.js index bbf21cf28ee..9ffddafe61b 100644 --- a/addons/web/static/src/views/graph/graph_model.js +++ b/addons/web/static/src/views/graph/graph_model.js @@ -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(); + } } } diff --git a/addons/web/static/src/views/graph/graph_renderer.js b/addons/web/static/src/views/graph/graph_renderer.js index 364d3216fb5..98c0b1d227a 100644 --- a/addons/web/static/src/views/graph/graph_renderer.js +++ b/addons/web/static/src/views/graph/graph_renderer.js @@ -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; diff --git a/addons/web/static/tests/views/graph_view_tests.js b/addons/web/static/tests/views/graph_view_tests.js index 792d0a58e20..35cd9b0e683 100644 --- a/addons/web/static/tests/views/graph_view_tests.js +++ b/addons/web/static/tests/views/graph_view_tests.js @@ -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] }, ]); });