[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:
@@ -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] },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user