Files
odoo_source/addons/web/static/src/views/graph/graph_renderer.js
T
Dardenne Florent (dafl) 455a061d09 [FIX] web: stacked bar is properly displayed
Stacked bar does not support the stacked option in the y axes.
This option was introduced in the stacked line generalization and was
set to False if the user is in the bar mode.

closes odoo/odoo#97410

Signed-off-by: Simon Genin (ges@odoo) <ges@odoo.com>
2022-08-03 16:31:11 +02:00

639 lines
23 KiB
JavaScript

/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { BORDER_WHITE, DEFAULT_BG, getColor, hexToRGBA } from "./colors";
import { formatFloat } from "@web/views/fields/formatters";
import { SEP } from "./graph_model";
import { sortBy } from "@web/core/utils/arrays";
import { loadJS } from "@web/core/assets";
import { renderToString } from "@web/core/utils/render";
const { Component, onWillUnmount, useEffect, useRef, onWillStart } = owl;
const NO_DATA = _lt("No data");
/**
* @param {Object} chartArea
* @returns {string}
*/
function getMaxWidth(chartArea) {
const { left, right } = chartArea;
return Math.floor((right - left) / 1.618) + "px";
}
/**
* Used to avoid too long legend items.
* @param {string|Strin} label
* @returns {string} shortened version of the input label
*/
function shortenLabel(label) {
// string returned could be wrong if a groupby value contain a " / "!
const groups = label.toString().split(SEP);
let shortLabel = groups.slice(0, 3).join(SEP);
if (shortLabel.length > 30) {
shortLabel = `${shortLabel.slice(0, 30)}...`;
} else if (groups.length > 3) {
shortLabel = `${shortLabel}${SEP}...`;
}
return shortLabel;
}
export class GraphRenderer extends Component {
setup() {
this.model = this.props.model;
this.rootRef = useRef("root");
this.canvasRef = useRef("canvas");
this.containerRef = useRef("container");
this.chart = null;
this.tooltip = null;
this.legendTooltip = null;
onWillStart(() => loadJS(["/web/static/lib/Chart/Chart.js"]));
useEffect(() => this.renderChart());
onWillUnmount(this.onWillUnmount);
}
onWillUnmount() {
if (this.chart) {
this.chart.destroy();
}
}
/**
* This function aims to remove a suitable number of lines from the
* tooltip in order to make it reasonably visible. A message indicating
* the number of lines is added if necessary.
* @param {HTMLElement} tooltip
* @param {number} maxTooltipHeight this the max height in pixels of the tooltip
*/
adjustTooltipHeight(tooltip, maxTooltipHeight) {
const sizeOneLine = tooltip.querySelector("tbody tr").clientHeight;
const tbodySize = tooltip.querySelector("tbody").clientHeight;
const toKeep = Math.max(
0,
Math.floor((maxTooltipHeight - (tooltip.clientHeight - tbodySize)) / sizeOneLine) - 1
);
const lines = tooltip.querySelectorAll("tbody tr");
const toRemove = lines.length - toKeep;
if (toRemove > 0) {
for (let index = toKeep; index < lines.length; ++index) {
lines[index].remove();
}
const tr = document.createElement("tr");
const td = document.createElement("td");
tr.classList.add("o_show_more", "text-center", "fw-bold");
td.setAttribute("colspan", "2");
td.innerText = this.env._t("...");
tr.appendChild(td);
tooltip.querySelector("tbody").appendChild(tr);
}
}
/**
* Creates a custom HTML tooltip.
* @param {Object} data
* @param {Object} metaData
* @param {Object} tooltipModel see chartjs documentation
*/
customTooltip(data, metaData, tooltipModel) {
const { measure, measures, disableLinking, mode } = metaData;
this.rootRef.el.style.cursor = "";
this.removeTooltips();
if (tooltipModel.opacity === 0 || tooltipModel.dataPoints.length === 0) {
return;
}
if (!disableLinking && mode !== "line") {
this.rootRef.el.style.cursor = "pointer";
}
const chartAreaTop = this.chart.chartArea.top;
const viewContentTop = this.rootRef.el.getBoundingClientRect().top;
const innerHTML = renderToString("web.GraphRenderer.CustomTooltip", {
maxWidth: getMaxWidth(this.chart.chartArea),
measure: measures[measure].string,
mode: this.model.metaData.mode,
tooltipItems: this.getTooltipItems(data, metaData, tooltipModel),
});
const template = Object.assign(document.createElement("template"), { innerHTML });
const tooltip = template.content.firstChild;
this.containerRef.el.prepend(tooltip);
let top;
const tooltipHeight = tooltip.clientHeight;
const minTopAllowed = Math.floor(chartAreaTop);
const maxTopAllowed = Math.floor(window.innerHeight - (viewContentTop + tooltipHeight)) - 2;
const y = Math.floor(tooltipModel.y);
if (minTopAllowed <= maxTopAllowed) {
// Here we know that the full tooltip can fit in the screen.
// We put it in the position where Chart.js would put it
// if two conditions are respected:
// 1: the tooltip is not cut (because we know it is possible to not cut it)
// 2: the tooltip does not hide the legend.
// If it is not possible to use the Chart.js proposition (y)
// we use the best approximated value.
if (y <= maxTopAllowed) {
if (y >= minTopAllowed) {
top = y;
} else {
top = minTopAllowed;
}
} else {
top = maxTopAllowed;
}
} else {
// Here we know that we cannot satisfy condition 1 above,
// so we position the tooltip at the minimal position and
// cut it the minimum possible.
top = minTopAllowed;
const maxTooltipHeight = window.innerHeight - (viewContentTop + chartAreaTop) - 2;
this.adjustTooltipHeight(tooltip, maxTooltipHeight);
}
this.fixTooltipLeftPosition(tooltip, tooltipModel.x);
tooltip.style.top = Math.floor(top) + "px";
this.tooltip = tooltip;
}
/**
* Sets best left position of a tooltip approaching the proposal x.
* @param {HTMLElement} tooltip
* @param {number} x
*/
fixTooltipLeftPosition(tooltip, x) {
let left;
const tooltipWidth = tooltip.clientWidth;
const minLeftAllowed = Math.floor(this.chart.chartArea.left + 2);
const maxLeftAllowed = Math.floor(this.chart.chartArea.right - tooltipWidth - 2);
x = Math.floor(x);
if (x < minLeftAllowed) {
left = minLeftAllowed;
} else if (x > maxLeftAllowed) {
left = maxLeftAllowed;
} else {
left = x;
}
tooltip.style.left = `${left}px`;
}
/**
* Used to format correctly the values in tooltips and yAxes.
* @param {number} value
* @param {boolean} [allIntegers=true]
* @returns {string}
*/
formatValue(value, allIntegers = true) {
const largeNumber = Math.abs(value) >= 1000;
if (allIntegers && !largeNumber) {
return String(value);
}
if (largeNumber) {
return formatFloat(value, { humanReadable: true, decimals: 2, minDigits: 1 });
}
return formatFloat(value);
}
/**
* Returns the bar chart data
* @returns {Object}
*/
getBarChartData() {
// style data
const { domains, stacked } = this.model.metaData;
const data = this.model.data;
for (let index = 0; index < data.datasets.length; ++index) {
const dataset = data.datasets[index];
// used when stacked
if (stacked) {
dataset.stack = domains[dataset.originIndex].description || "";
}
// set dataset color
dataset.backgroundColor = getColor(index);
}
return data;
}
/**
* Returns the chart config.
* @returns {Object}
*/
getChartConfig() {
const { mode } = this.model.metaData;
let data;
switch (mode) {
case "bar":
data = this.getBarChartData();
break;
case "line":
data = this.getLineChartData();
break;
case "pie":
data = this.getPieChartData();
}
const options = this.prepareOptions();
return { data, options, type: mode };
}
/**
* Returns an object used to style chart elements independently from
* the datasets.
* @returns {Object}
*/
getElementOptions() {
const { mode, stacked } = this.model.metaData;
const elementOptions = {};
if (mode === "bar") {
elementOptions.rectangle = { borderWidth: 1 };
} else if (mode === "line") {
elementOptions.line = { fill: stacked, tension: 0 };
}
return elementOptions;
}
/**
* @returns {Object}
*/
getLegendOptions() {
const { mode } = this.model.metaData;
const data = this.model.data;
const refLength = mode === "pie" ? data.labels.length : data.datasets.length;
const legendOptions = {
display: refLength <= 20,
position: "top",
onHover: this.onlegendHover.bind(this),
onLeave: this.onLegendLeave.bind(this),
};
if (mode === "line") {
legendOptions.onClick = this.onLegendClick.bind(this);
}
if (mode === "pie") {
legendOptions.labels = {
generateLabels: (chart) => {
const { data } = chart;
const metaData = data.datasets.map(
(_, index) => chart.getDatasetMeta(index).data
);
const labels = data.labels.map((label, index) => {
const hidden = metaData.some((data) => data[index] && data[index].hidden);
const fullText = label;
const text = shortenLabel(fullText);
const fillStyle = label === NO_DATA ? DEFAULT_BG : getColor(index);
return { text, fullText, fillStyle, hidden, index };
});
return labels;
},
};
} else {
const referenceColor = mode === "bar" ? "backgroundColor" : "borderColor";
legendOptions.labels = {
generateLabels: (chart) => {
const { data } = chart;
const labels = data.datasets.map((dataset, index) => {
return {
text: shortenLabel(dataset.label),
fullText: dataset.label,
fillStyle: dataset[referenceColor],
hidden: !chart.isDatasetVisible(index),
lineCap: dataset.borderCapStyle,
lineDash: dataset.borderDash,
lineDashOffset: dataset.borderDashOffset,
lineJoin: dataset.borderJoinStyle,
lineWidth: dataset.borderWidth,
strokeStyle: dataset[referenceColor],
pointStyle: dataset.pointStyle,
datasetIndex: index,
};
});
return labels;
},
};
}
return legendOptions;
}
/**
* Returns line chart data.
* @returns {Object}
*/
getLineChartData() {
const { groupBy, domains, stacked } = this.model.metaData;
const data = this.model.data;
for (let index = 0; index < data.datasets.length; ++index) {
const dataset = data.datasets[index];
if (groupBy.length <= 1 && domains.length > 1) {
if (dataset.originIndex === 0) {
dataset.fill = "origin";
dataset.backgroundColor = hexToRGBA(getColor(0), 0.4);
dataset.borderColor = getColor(0);
} else if (dataset.originIndex === 1) {
dataset.borderColor = getColor(1);
} else {
dataset.borderColor = getColor(index);
}
} else {
dataset.borderColor = getColor(index);
}
if (data.labels.length === 1) {
// shift of the real value to right. This is done to
// center the points in the chart. See data.labels below in
// Chart parameters
dataset.data.unshift(undefined);
dataset.trueLabels.unshift(undefined);
dataset.domains.unshift(undefined);
}
dataset.pointBackgroundColor = dataset.borderColor;
dataset.pointBorderColor = "rgba(0,0,0,0.2)";
if (stacked) {
dataset.backgroundColor = hexToRGBA(dataset.borderColor, 0.4);
}
}
if (data.datasets.length === 1 && data.datasets[0].originIndex === 0) {
const dataset = data.datasets[0];
dataset.fill = "origin";
dataset.backgroundColor = hexToRGBA(getColor(0), 0.4);
}
// center the points in the chart (without that code they are put
// on the left and the graph seems empty)
data.labels = data.labels.length > 1 ? data.labels : ["", ...data.labels, ""];
return data;
}
/**
* Returns pie chart data.
* @returns {Object}
*/
getPieChartData() {
const { domains } = this.model.metaData;
const data = this.model.data;
// style/complete data
// give same color to same groups from different origins
const colors = data.labels.map((_, index) => getColor(index));
for (const dataset of data.datasets) {
dataset.backgroundColor = colors;
dataset.borderColor = BORDER_WHITE;
}
// make sure there is a zone associated with every origin
const representedOriginIndexes = new Set(
data.datasets.map((dataset) => dataset.originIndex)
);
let addNoDataToLegend = false;
const fakeData = new Array(data.labels.length + 1);
fakeData[data.labels.length] = 1;
const fakeTrueLabels = new Array(data.labels.length + 1);
fakeTrueLabels[data.labels.length] = NO_DATA;
for (let index = 0; index < domains.length; ++index) {
if (!representedOriginIndexes.has(index)) {
data.datasets.push({
label: domains[index].description,
data: fakeData,
trueLabels: fakeTrueLabels,
backgroundColor: [...colors, DEFAULT_BG],
borderColor: BORDER_WHITE,
});
addNoDataToLegend = true;
}
}
if (addNoDataToLegend) {
data.labels.push(NO_DATA);
}
return data;
}
/**
* Returns the options used to generate the chart axes.
* @returns {Object}
*/
getScaleOptions() {
const {
allIntegers,
displayScaleLabels,
fields,
groupBy,
measure,
measures,
mode,
stacked,
} = this.model.metaData;
if (mode === "pie") {
return {};
}
const xAxe = {
type: "category",
scaleLabel: {
display: Boolean(groupBy.length && displayScaleLabels),
labelString: groupBy.length ? fields[groupBy[0].fieldName].string : "",
},
};
const yAxe = {
type: "linear",
scaleLabel: {
display: displayScaleLabels,
labelString: measures[measure].string,
},
ticks: {
callback: (value) => this.formatValue(value, allIntegers),
suggestedMax: 0,
suggestedMin: 0,
},
stacked: mode === "line" && stacked ? stacked : undefined,
};
return { xAxes: [xAxe], yAxes: [yAxe] };
}
/**
* This function extracts the information from the data points in
* tooltipModel.dataPoints (corresponding to datapoints over a given
* label determined by the mouse position) that will be displayed in a
* custom tooltip.
* @param {Object} data
* @param {Object} metaData
* @param {Object} tooltipModel see chartjs documentation
* @returns {Object[]}
*/
getTooltipItems(data, metaData, tooltipModel) {
const { allIntegers, domains, mode, groupBy } = metaData;
const sortedDataPoints = sortBy(tooltipModel.dataPoints, "yLabel", "desc");
const items = [];
for (const item of sortedDataPoints) {
const index = item.index;
const dataset = data.datasets[item.datasetIndex];
let label = dataset.trueLabels[index];
let value = this.formatValue(dataset.data[index], allIntegers);
let boxColor;
let percentage;
if (mode === "pie") {
if (label === NO_DATA) {
value = this.formatValue(0, allIntegers);
}
if (domains.length > 1) {
label = `${dataset.label} / ${label}`;
}
boxColor = dataset.backgroundColor[index];
const totalData = dataset.data.reduce((a, b) => a + b, 0);
percentage = totalData && ((dataset.data[item.index] * 100) / totalData).toFixed(2);
} else {
if (groupBy.length > 1 || domains.length > 1) {
label = `${label} / ${dataset.label}`;
}
boxColor = mode === "bar" ? dataset.backgroundColor : dataset.borderColor;
}
items.push({ label, value, boxColor, percentage });
}
return items;
}
/**
* Returns the options used to generate chart tooltips.
* @returns {Object}
*/
getTooltipOptions() {
const { data, metaData } = this.model;
const { mode } = metaData;
const tooltipOptions = {
enabled: false,
custom: this.customTooltip.bind(this, data, metaData),
};
if (mode === "line") {
tooltipOptions.mode = "index";
tooltipOptions.intersect = false;
}
return tooltipOptions;
}
/**
* If a group has been clicked on, display a view of its records.
* @param {MouseEvent} ev
*/
onGraphClicked(ev) {
const [activeElement] = this.chart.getElementAtEvent(ev);
if (!activeElement) {
return;
}
const { _datasetIndex, _index } = activeElement;
const { domains } = this.chart.data.datasets[_datasetIndex];
if (domains) {
this.props.onGraphClicked(domains[_index]);
}
}
/**
* Overrides the default legend 'onClick' behaviour. This is done to
* remove all existing tooltips right before updating the chart.
* @param {Event} ev
* @param {Object} legendItem
*/
onLegendClick(ev, legendItem) {
this.removeTooltips();
// Default 'onClick' fallback. See web/static/lib/Chart/Chart.js#15138
const index = legendItem.datasetIndex;
const meta = this.chart.getDatasetMeta(index);
meta.hidden = meta.hidden === null ? !this.chart.data.datasets[index].hidden : null;
this.chart.update();
}
/**
* If the text of a legend item has been shortened and the user mouse
* hovers that item (actually the event type is mousemove), a tooltip
* with the item full text is displayed.
* @param {Event} ev
* @param {Object} legendItem
*/
onlegendHover(ev, legendItem) {
this.canvasRef.el.style.cursor = "pointer";
/**
* The string legendItem.text is an initial segment of legendItem.fullText.
* If the two coincide, no need to generate a tooltip. If a tooltip
* for the legend already exists, it is already good and does not
* need to be recreated.
*/
const { fullText, text } = legendItem;
if (this.legendTooltip || text === fullText) {
return;
}
const viewContentTop = this.rootRef.el.getBoundingClientRect().top;
const legendTooltip = Object.assign(document.createElement("div"), {
className: "o_tooltip_legend popover p-3 o_debounce_disabled",
innerText: fullText,
});
legendTooltip.style.top = `${ev.clientY - viewContentTop}px`;
legendTooltip.style.maxWidth = getMaxWidth(this.chart.chartArea);
this.containerRef.el.appendChild(legendTooltip);
this.fixTooltipLeftPosition(legendTooltip, ev.clientX);
this.legendTooltip = legendTooltip;
}
/**
* If there's a legend tooltip and the user mouse out of the
* corresponding legend item, the tooltip is removed.
*/
onLegendLeave() {
this.canvasRef.el.style.cursor = "";
this.removeLegendTooltip();
}
/**
* Prepares options for the chart according to the current mode
* (= chart type). This function returns the parameter options used to
* instantiate the chart.
*/
prepareOptions() {
const { disableLinking, mode } = this.model.metaData;
const options = {
maintainAspectRatio: false,
scales: this.getScaleOptions(),
legend: this.getLegendOptions(),
tooltips: this.getTooltipOptions(),
elements: this.getElementOptions(),
};
if (!disableLinking && mode !== "line") {
options.onClick = this.onGraphClicked.bind(this);
}
return options;
}
/**
* Removes the legend tooltip (if any).
*/
removeLegendTooltip() {
if (this.legendTooltip) {
this.legendTooltip.remove();
this.legendTooltip = null;
}
}
/**
* Removes all existing tooltips (if any).
*/
removeTooltips() {
if (this.tooltip) {
this.tooltip.remove();
this.tooltip = null;
}
this.removeLegendTooltip();
}
/**
* Instantiates a Chart (Chart.js lib) to render the graph according to
* the current config.
*/
renderChart() {
if (this.chart) {
this.chart.destroy();
}
const config = this.getChartConfig();
this.chart = new Chart(this.canvasRef.el, config);
// To perform its animations, ChartJS will perform each animation
// step in the next animation frame. The initial rendering itself
// is delayed for consistency. We can avoid this by manually
// advancing the animation service.
Chart.animationService.advance();
}
}
GraphRenderer.template = "web.GraphRenderer";
GraphRenderer.props = ["class?", "model", "onGraphClicked"];