7 changed files with 10 additions and 1441 deletions
@ -1,32 +1,10 @@ |
|||||
/** @odoo-module **/ |
/** @odoo-module **/ |
||||
|
import { COLORS } from "@web/views/graph/colors"; |
||||
|
|
||||
export const COLORS = ["#556ee6", "#f1b44c", "#50a5f1", "#ffbb78", "#34c38f", "#98df8a", "#d62728", |
var code_backend_color = ["#556ee6", "#f1b44c", "#50a5f1", "#ffbb78", "#34c38f", "#98df8a", "#d62728", |
||||
"#ff9896", "#9467bd", "#c5b0d5", "#8c564b", "#c49c94", "#e377c2", "#f7b6d2", |
"#ff9896", "#9467bd", "#c5b0d5", "#8c564b", "#c49c94", "#e377c2", "#f7b6d2", |
||||
"#7f7f7f", "#c7c7c7", "#bcbd22", "#dbdb8d", "#17becf", "#9edae5"]; |
"#7f7f7f", "#c7c7c7", "#bcbd22", "#dbdb8d", "#17becf", "#9edae5"]; |
||||
|
|
||||
/** |
for (let i=0;i<=code_backend_color.length;i++){ |
||||
* @param {number} index |
COLORS[i] = code_backend_color[i] |
||||
* @returns {string} |
|
||||
*/ |
|
||||
export function getColor(index) { |
|
||||
return COLORS[index % COLORS.length]; |
|
||||
} |
|
||||
|
|
||||
export const DEFAULT_BG = "#d3d3d3"; |
|
||||
|
|
||||
export const BORDER_WHITE = "rgba(255,255,255,0.6)"; |
|
||||
|
|
||||
const RGB_REGEX = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i; |
|
||||
|
|
||||
/** |
|
||||
* @param {string} hex |
|
||||
* @param {number} opacity |
|
||||
* @returns {string} |
|
||||
*/ |
|
||||
export function hexToRGBA(hex, opacity) { |
|
||||
const rgb = RGB_REGEX.exec(hex) |
|
||||
.slice(1, 4) |
|
||||
.map((n) => parseInt(n, 16)) |
|
||||
.join(","); |
|
||||
return `rgba(${rgb},${opacity})`; |
|
||||
} |
} |
||||
|
@ -1,82 +0,0 @@ |
|||||
/** @odoo-module **/ |
|
||||
|
|
||||
import { evaluateExpr } from "@web/core/py_js/py"; |
|
||||
import { GROUPABLE_TYPES } from "@web/search/utils/misc"; |
|
||||
import { XMLParser } from "@web/core/utils/xml"; |
|
||||
import { archParseBoolean } from "@web/views/helpers/utils"; |
|
||||
|
|
||||
const MODES = ["bar", "line", "pie"]; |
|
||||
const ORDERS = ["ASC", "DESC", null]; |
|
||||
|
|
||||
export class GraphArchParser extends XMLParser { |
|
||||
parse(arch, fields = {}) { |
|
||||
const archInfo = { fields, fieldAttrs: {}, groupBy: [] }; |
|
||||
this.visitXML(arch, (node) => { |
|
||||
switch (node.tagName) { |
|
||||
case "graph": { |
|
||||
if (node.hasAttribute("disable_linking")) { |
|
||||
archInfo.disableLinking = archParseBoolean( |
|
||||
node.getAttribute("disable_linking") |
|
||||
); |
|
||||
} |
|
||||
if (node.hasAttribute("stacked")) { |
|
||||
archInfo.stacked = archParseBoolean(node.getAttribute("stacked")); |
|
||||
} |
|
||||
const mode = node.getAttribute("type"); |
|
||||
if (mode && MODES.includes(mode)) { |
|
||||
archInfo.mode = mode; |
|
||||
} |
|
||||
const order = node.getAttribute("order"); |
|
||||
if (order && ORDERS.includes(order)) { |
|
||||
archInfo.order = order; |
|
||||
} |
|
||||
const title = node.getAttribute("string"); |
|
||||
if (title) { |
|
||||
archInfo.title = title; |
|
||||
} |
|
||||
break; |
|
||||
} |
|
||||
case "field": { |
|
||||
let fieldName = node.getAttribute("name"); // exists (rng validation)
|
|
||||
if (fieldName === "id") { |
|
||||
break; |
|
||||
} |
|
||||
const string = node.getAttribute("string"); |
|
||||
if (string) { |
|
||||
if (!archInfo.fieldAttrs[fieldName]) { |
|
||||
archInfo.fieldAttrs[fieldName] = {}; |
|
||||
} |
|
||||
archInfo.fieldAttrs[fieldName].string = string; |
|
||||
} |
|
||||
const isInvisible = Boolean( |
|
||||
evaluateExpr(node.getAttribute("invisible") || "0") |
|
||||
); |
|
||||
if (isInvisible) { |
|
||||
if (!archInfo.fieldAttrs[fieldName]) { |
|
||||
archInfo.fieldAttrs[fieldName] = {}; |
|
||||
} |
|
||||
archInfo.fieldAttrs[fieldName].isInvisible = true; |
|
||||
break; |
|
||||
} |
|
||||
const isMeasure = node.getAttribute("type") === "measure"; |
|
||||
if (isMeasure) { |
|
||||
// the last field with type="measure" (if any) will be used as measure else __count
|
|
||||
archInfo.measure = fieldName; |
|
||||
} else { |
|
||||
const { type } = archInfo.fields[fieldName]; // exists (rng validation)
|
|
||||
if (GROUPABLE_TYPES.includes(type)) { |
|
||||
let groupBy = fieldName; |
|
||||
const interval = node.getAttribute("interval"); |
|
||||
if (interval) { |
|
||||
groupBy += `:${interval}`; |
|
||||
} |
|
||||
archInfo.groupBy.push(groupBy); |
|
||||
} |
|
||||
} |
|
||||
break; |
|
||||
} |
|
||||
} |
|
||||
}); |
|
||||
return archInfo; |
|
||||
} |
|
||||
} |
|
@ -1,537 +0,0 @@ |
|||||
/** @odoo-module **/ |
|
||||
|
|
||||
import { sortBy } 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/helpers/model"; |
|
||||
import { computeReportMeasures, processMeasure } from "@web/views/helpers/utils"; |
|
||||
|
|
||||
export const SEP = " / "; |
|
||||
|
|
||||
/** |
|
||||
* @typedef {import("@web/search/search_model").SearchParams} SearchParams |
|
||||
*/ |
|
||||
|
|
||||
class DateClasses { |
|
||||
// We view the param "array" as a matrix of values and undefined.
|
|
||||
// An equivalence class is formed of defined values of a column.
|
|
||||
// So nothing has to do with dates but we only use Dateclasses to manage
|
|
||||
// identification of dates.
|
|
||||
/** |
|
||||
* @param {(any[])[]} array |
|
||||
*/ |
|
||||
constructor(array) { |
|
||||
this.__referenceIndex = null; |
|
||||
this.__array = array; |
|
||||
for (let i = 0; i < this.__array.length; i++) { |
|
||||
const arr = this.__array[i]; |
|
||||
if (arr.length && this.__referenceIndex === null) { |
|
||||
this.__referenceIndex = i; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* @param {number} index |
|
||||
* @param {any} o |
|
||||
* @returns {string} |
|
||||
*/ |
|
||||
classLabel(index, o) { |
|
||||
return `${this.__array[index].indexOf(o)}`; |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* @param {string} classLabel |
|
||||
* @returns {any[]} |
|
||||
*/ |
|
||||
classMembers(classLabel) { |
|
||||
const classNumber = Number(classLabel); |
|
||||
const classMembers = new Set(); |
|
||||
for (const arr of this.__array) { |
|
||||
if (arr[classNumber] !== undefined) { |
|
||||
classMembers.add(arr[classNumber]); |
|
||||
} |
|
||||
} |
|
||||
return [...classMembers]; |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* @param {string} classLabel |
|
||||
* @param {number} [index] |
|
||||
* @returns {any} |
|
||||
*/ |
|
||||
representative(classLabel, index) { |
|
||||
const classNumber = Number(classLabel); |
|
||||
const i = index === undefined ? this.__referenceIndex : index; |
|
||||
if (i === null) { |
|
||||
return null; |
|
||||
} |
|
||||
return this.__array[i][classNumber]; |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* @param {number} index |
|
||||
* @returns {number} |
|
||||
*/ |
|
||||
arrayLength(index) { |
|
||||
return this.__array[index].length; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export class GraphModel extends Model { |
|
||||
/** |
|
||||
* @override |
|
||||
*/ |
|
||||
setup(params) { |
|
||||
// concurrency management
|
|
||||
this.keepLast = new KeepLast(); |
|
||||
this.race = new Race(); |
|
||||
const _fetchDataPoints = this._fetchDataPoints.bind(this); |
|
||||
this._fetchDataPoints = (...args) => { |
|
||||
return this.race.add(_fetchDataPoints(...args)); |
|
||||
}; |
|
||||
|
|
||||
this.initialGroupBy = null; |
|
||||
|
|
||||
this.metaData = params; |
|
||||
this.data = null; |
|
||||
this.searchParams = null; |
|
||||
} |
|
||||
|
|
||||
//--------------------------------------------------------------------------
|
|
||||
// Public
|
|
||||
//--------------------------------------------------------------------------
|
|
||||
|
|
||||
/** |
|
||||
* @param {SearchParams} searchParams |
|
||||
*/ |
|
||||
async load(searchParams) { |
|
||||
this.searchParams = searchParams; |
|
||||
if (!this.initialGroupBy) { |
|
||||
this.initialGroupBy = searchParams.context.graph_groupbys || this.metaData.groupBy; // = arch groupBy --> change that
|
|
||||
} |
|
||||
const metaData = this._buildMetaData(); |
|
||||
return this._fetchDataPoints(metaData); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* @override |
|
||||
*/ |
|
||||
hasData() { |
|
||||
return this.dataPoints.length > 0; |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Only supposed to be called to change one or several parameters among |
|
||||
* "measure", "mode", "order", and "stacked". |
|
||||
* @param {Object} params |
|
||||
*/ |
|
||||
async updateMetaData(params) { |
|
||||
if ("measure" in params) { |
|
||||
const metaData = this._buildMetaData(params); |
|
||||
await this._fetchDataPoints(metaData); |
|
||||
} else { |
|
||||
await this.race.getCurrentProm(); |
|
||||
this.metaData = Object.assign({}, this.metaData, params); |
|
||||
this._prepareData(); |
|
||||
} |
|
||||
this.notify(); |
|
||||
} |
|
||||
|
|
||||
//--------------------------------------------------------------------------
|
|
||||
// Protected
|
|
||||
//--------------------------------------------------------------------------
|
|
||||
|
|
||||
/** |
|
||||
* @protected |
|
||||
* @param {Object} [params={}] |
|
||||
* @returns {Object} |
|
||||
*/ |
|
||||
_buildMetaData(params) { |
|
||||
const { comparison, domain, context, groupBy } = this.searchParams; |
|
||||
|
|
||||
const metaData = Object.assign({}, this.metaData, { context }); |
|
||||
if (comparison) { |
|
||||
metaData.domains = comparison.domains; |
|
||||
metaData.comparisonField = comparison.fieldName; |
|
||||
} else { |
|
||||
metaData.domains = [{ arrayRepr: domain, description: null }]; |
|
||||
} |
|
||||
metaData.measure = context.graph_measure || metaData.measure; |
|
||||
metaData.mode = context.graph_mode || metaData.mode; |
|
||||
metaData.groupBy = groupBy.length ? groupBy : this.initialGroupBy; |
|
||||
|
|
||||
this._normalize(metaData); |
|
||||
|
|
||||
metaData.measures = computeReportMeasures( |
|
||||
metaData.fields, |
|
||||
metaData.fieldAttrs, |
|
||||
[metaData.measure], |
|
||||
metaData.additionalMeasures |
|
||||
); |
|
||||
|
|
||||
return Object.assign(metaData, params); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Fetch the data points determined by the metaData. This function has |
|
||||
* several side effects. It can alter this.metaData and set this.dataPoints. |
|
||||
* @protected |
|
||||
* @param {Object} metaData |
|
||||
*/ |
|
||||
async _fetchDataPoints(metaData) { |
|
||||
this.dataPoints = await this.keepLast.add(this._loadDataPoints(metaData)); |
|
||||
this.metaData = metaData; |
|
||||
this._prepareData(); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Separates dataPoints coming from the read_group(s) into different |
|
||||
* datasets. This function returns the parameters data and labels used |
|
||||
* to produce the charts. |
|
||||
* @protected |
|
||||
* @param {Object[]} |
|
||||
* @returns {Object} |
|
||||
*/ |
|
||||
_getData(dataPoints) { |
|
||||
const { comparisonField, groupBy, mode } = this.metaData; |
|
||||
|
|
||||
let identify = false; |
|
||||
if (comparisonField && groupBy.length && groupBy[0].fieldName === comparisonField) { |
|
||||
identify = true; |
|
||||
} |
|
||||
const dateClasses = identify ? this._getDateClasses(dataPoints) : null; |
|
||||
|
|
||||
// dataPoints --> labels
|
|
||||
let labels = []; |
|
||||
const labelMap = {}; |
|
||||
for (const dataPt of dataPoints) { |
|
||||
const x = dataPt.labels.slice(0, mode === "pie" ? undefined : 1); |
|
||||
const trueLabel = x.length ? x.join(SEP) : this.env._t("Total"); |
|
||||
if (dateClasses) { |
|
||||
x[0] = dateClasses.classLabel(dataPt.originIndex, x[0]); |
|
||||
} |
|
||||
const key = JSON.stringify(x); |
|
||||
if (labelMap[key] === undefined) { |
|
||||
labelMap[key] = labels.length; |
|
||||
if (dateClasses) { |
|
||||
if (mode === "pie") { |
|
||||
x[0] = dateClasses.classMembers(x[0]).join(", "); |
|
||||
} else { |
|
||||
x[0] = dateClasses.representative(x[0]); |
|
||||
} |
|
||||
} |
|
||||
const label = x.length ? x.join(SEP) : this.env._t("Total"); |
|
||||
labels.push(label); |
|
||||
} |
|
||||
dataPt.labelIndex = labelMap[key]; |
|
||||
dataPt.trueLabel = trueLabel; |
|
||||
} |
|
||||
|
|
||||
// dataPoints + labels --> datasetsTmp --> datasets
|
|
||||
const datasetsTmp = {}; |
|
||||
for (const dataPt of dataPoints) { |
|
||||
const { domain, labelIndex, originIndex, trueLabel, value } = dataPt; |
|
||||
const datasetLabel = this._getDatasetLabel(dataPt); |
|
||||
if (!(datasetLabel in datasetsTmp)) { |
|
||||
let dataLength = labels.length; |
|
||||
if (mode !== "pie" && dateClasses) { |
|
||||
dataLength = dateClasses.arrayLength(originIndex); |
|
||||
} |
|
||||
datasetsTmp[datasetLabel] = { |
|
||||
data: new Array(dataLength).fill(0), |
|
||||
trueLabels: labels.slice(0, dataLength), // should be good // check this in case identify = true
|
|
||||
domains: new Array(dataLength).fill([]), |
|
||||
label: datasetLabel, |
|
||||
originIndex: originIndex, |
|
||||
}; |
|
||||
} |
|
||||
datasetsTmp[datasetLabel].data[labelIndex] = value; |
|
||||
datasetsTmp[datasetLabel].domains[labelIndex] = domain; |
|
||||
datasetsTmp[datasetLabel].trueLabels[labelIndex] = trueLabel; |
|
||||
} |
|
||||
// sort by origin
|
|
||||
let datasets = sortBy(Object.values(datasetsTmp), "originIndex"); |
|
||||
|
|
||||
if (mode === "pie") { |
|
||||
// We kinda have a matrix. We remove the zero columns and rows. This is a global operation.
|
|
||||
// That's why it cannot be done before.
|
|
||||
datasets = datasets.filter((dataset) => dataset.data.some((v) => Boolean(v))); |
|
||||
const labelsToKeepIndexes = {}; |
|
||||
labels.forEach((_, index) => { |
|
||||
if (datasets.some((dataset) => Boolean(dataset.data[index]))) { |
|
||||
labelsToKeepIndexes[index] = true; |
|
||||
} |
|
||||
}); |
|
||||
labels = labels.filter((_, index) => labelsToKeepIndexes[index]); |
|
||||
for (const dataset of datasets) { |
|
||||
dataset.data = dataset.data.filter((_, index) => labelsToKeepIndexes[index]); |
|
||||
dataset.domains = dataset.domains.filter((_, index) => labelsToKeepIndexes[index]); |
|
||||
dataset.trueLabels = dataset.trueLabels.filter( |
|
||||
(_, index) => labelsToKeepIndexes[index] |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return { datasets, labels }; |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Determines the dataset to which the data point belongs. |
|
||||
* @protected |
|
||||
* @param {Object} dataPoint |
|
||||
* @returns {string} |
|
||||
*/ |
|
||||
_getDatasetLabel(dataPoint) { |
|
||||
const { measure, measures, domains, mode } = this.metaData; |
|
||||
const { labels, originIndex } = dataPoint; |
|
||||
if (mode === "pie") { |
|
||||
return domains[originIndex].description || ""; |
|
||||
} |
|
||||
// ([origin] + second to last groupBys) or measure
|
|
||||
let datasetLabel = labels.slice(1).join(SEP); |
|
||||
if (domains.length > 1) { |
|
||||
datasetLabel = |
|
||||
domains[originIndex].description + (datasetLabel ? SEP + datasetLabel : ""); |
|
||||
} |
|
||||
datasetLabel = datasetLabel || measures[measure].string; |
|
||||
return datasetLabel; |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* @protected |
|
||||
* @param {Object[]} dataPoints |
|
||||
* @returns {DateClasses} |
|
||||
*/ |
|
||||
_getDateClasses(dataPoints) { |
|
||||
const { domains } = this.metaData; |
|
||||
const dateSets = domains.map(() => new Set()); |
|
||||
for (const { labels, originIndex } of dataPoints) { |
|
||||
const date = labels[0]; |
|
||||
dateSets[originIndex].add(date); |
|
||||
} |
|
||||
const arrays = dateSets.map((dateSet) => [...dateSet]); |
|
||||
return new DateClasses(arrays); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Eventually filters and sort data points. |
|
||||
* @protected |
|
||||
* @returns {Object[]} |
|
||||
*/ |
|
||||
_getProcessedDataPoints() { |
|
||||
const { domains, groupBy, mode, order } = this.metaData; |
|
||||
let processedDataPoints = []; |
|
||||
if (mode === "line") { |
|
||||
processedDataPoints = this.dataPoints.filter( |
|
||||
(dataPoint) => dataPoint.labels[0] !== this.env._t("Undefined") |
|
||||
); |
|
||||
} else { |
|
||||
processedDataPoints = this.dataPoints.filter((dataPoint) => dataPoint.count !== 0); |
|
||||
} |
|
||||
|
|
||||
if (order !== null && mode !== "pie" && domains.length === 1 && groupBy.length > 0) { |
|
||||
// group data by their x-axis value, and then sort datapoints
|
|
||||
// based on the sum of values by group in ascending/descending order
|
|
||||
const groupedDataPoints = {}; |
|
||||
for (const dataPt of processedDataPoints) { |
|
||||
const key = dataPt.labels[0]; // = x-axis value under the current assumptions
|
|
||||
if (!groupedDataPoints[key]) { |
|
||||
groupedDataPoints[key] = []; |
|
||||
} |
|
||||
groupedDataPoints[key].push(dataPt); |
|
||||
} |
|
||||
const groups = Object.values(groupedDataPoints); |
|
||||
const groupTotal = (group) => group.reduce((sum, dataPt) => sum + dataPt.value, 0); |
|
||||
processedDataPoints = sortBy(groups, groupTotal, order.toLowerCase()).flat(); |
|
||||
} |
|
||||
|
|
||||
return processedDataPoints; |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Determines whether the set of data points is good. If not, this.data will be (re)set to null |
|
||||
* @protected |
|
||||
* @param {Object[]} |
|
||||
* @returns {boolean} |
|
||||
*/ |
|
||||
_isValidData(dataPoints) { |
|
||||
const { mode } = this.metaData; |
|
||||
let somePositive = false; |
|
||||
let someNegative = false; |
|
||||
if (mode === "pie") { |
|
||||
for (const dataPt of dataPoints) { |
|
||||
if (dataPt.value > 0) { |
|
||||
somePositive = true; |
|
||||
} else if (dataPt.value < 0) { |
|
||||
someNegative = true; |
|
||||
} |
|
||||
} |
|
||||
if (someNegative && somePositive) { |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Fetch and process graph data. It is basically a(some) read_group(s) |
|
||||
* with correct fields for each domain. We have to do some light processing |
|
||||
* to separate date groups in the field list, because they can be defined |
|
||||
* with an aggregation function, such as my_date:week. |
|
||||
* @protected |
|
||||
* @param {Object} metaData |
|
||||
* @returns {Object[]} |
|
||||
*/ |
|
||||
async _loadDataPoints(metaData) { |
|
||||
const { measure, domains, fields, groupBy, resModel } = metaData; |
|
||||
|
|
||||
const measures = ["__count"]; |
|
||||
if (measure !== "__count") { |
|
||||
let { group_operator, type } = fields[measure]; |
|
||||
if (type === "many2one") { |
|
||||
group_operator = "count_distinct"; |
|
||||
} |
|
||||
if (group_operator === undefined) { |
|
||||
throw new Error( |
|
||||
`No aggregate function has been provided for the measure '${measure}'` |
|
||||
); |
|
||||
} |
|
||||
measures.push(`${measure}:${group_operator}`); |
|
||||
} |
|
||||
|
|
||||
const proms = []; |
|
||||
const numbering = {}; // used to avoid ambiguity with many2one with values with same labels:
|
|
||||
// for instance [1, "ABC"] [3, "ABC"] should be distinguished.
|
|
||||
domains.forEach((domain, originIndex) => { |
|
||||
proms.push( |
|
||||
this.orm |
|
||||
.webReadGroup( |
|
||||
resModel, |
|
||||
domain.arrayRepr, |
|
||||
measures, |
|
||||
groupBy.map((gb) => gb.spec), |
|
||||
{ lazy: false }, // what is this thing???
|
|
||||
{ fill_temporal: true, ...this.searchParams.context } |
|
||||
) |
|
||||
.then((data) => { |
|
||||
const dataPoints = []; |
|
||||
for (const group of data.groups) { |
|
||||
const { __domain, __count } = group; |
|
||||
const labels = []; |
|
||||
|
|
||||
for (const gb of groupBy) { |
|
||||
let label; |
|
||||
const val = group[gb.spec]; |
|
||||
const fieldName = gb.fieldName; |
|
||||
const { type } = fields[fieldName]; |
|
||||
if (type === "boolean") { |
|
||||
label = `${val}`; // toUpperCase?
|
|
||||
} else if (val === false) { |
|
||||
label = this.env._t("Undefined"); |
|
||||
} else if (type === "many2one") { |
|
||||
const [id, name] = val; |
|
||||
const key = JSON.stringify([fieldName, name]); |
|
||||
if (!numbering[key]) { |
|
||||
numbering[key] = {}; |
|
||||
} |
|
||||
const numbers = numbering[key]; |
|
||||
if (!numbers[id]) { |
|
||||
numbers[id] = Object.keys(numbers).length + 1; |
|
||||
} |
|
||||
const num = numbers[id]; |
|
||||
label = num === 1 ? name : `${name} (${num})`; |
|
||||
} else if (type === "selection") { |
|
||||
const selected = fields[fieldName].selection.find( |
|
||||
(s) => s[0] === val |
|
||||
); |
|
||||
label = selected[1]; |
|
||||
} else { |
|
||||
label = val; |
|
||||
} |
|
||||
labels.push(label); |
|
||||
} |
|
||||
|
|
||||
let value = group[measure]; |
|
||||
if (value instanceof Array) { |
|
||||
// case where measure is a many2one and is used as groupBy
|
|
||||
value = 1; |
|
||||
} |
|
||||
if (!Number.isInteger(value)) { |
|
||||
metaData.allIntegers = false; |
|
||||
} |
|
||||
dataPoints.push({ |
|
||||
count: __count, |
|
||||
domain: __domain, |
|
||||
value, |
|
||||
labels, |
|
||||
originIndex, |
|
||||
}); |
|
||||
} |
|
||||
return dataPoints; |
|
||||
}) |
|
||||
); |
|
||||
}); |
|
||||
const promResults = await Promise.all(proms); |
|
||||
return promResults.flat(); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Process metaData.groupBy in order to keep only the finest interval option for |
|
||||
* elements based on date/datetime field (e.g. 'date:year'). This means that |
|
||||
* 'week' is prefered to 'month'. The field stays at the place of its first occurence. |
|
||||
* For instance, |
|
||||
* ['foo', 'date:month', 'bar', 'date:week'] becomes ['foo', 'date:week', 'bar']. |
|
||||
* @protected |
|
||||
* @param {Object} metaData |
|
||||
*/ |
|
||||
_normalize(metaData) { |
|
||||
const { fields } = metaData; |
|
||||
const groupBy = []; |
|
||||
for (const gb of metaData.groupBy) { |
|
||||
let ngb = gb; |
|
||||
if (typeof gb === "string") { |
|
||||
ngb = getGroupBy(gb, fields); |
|
||||
} |
|
||||
groupBy.push(ngb); |
|
||||
} |
|
||||
|
|
||||
const processedGroupBy = []; |
|
||||
for (const gb of groupBy) { |
|
||||
const { fieldName, interval } = gb; |
|
||||
const { store, type } = fields[fieldName]; |
|
||||
if ( |
|
||||
!store || |
|
||||
["id", "__count"].includes(fieldName) || |
|
||||
!GROUPABLE_TYPES.includes(type) |
|
||||
) { |
|
||||
continue; |
|
||||
} |
|
||||
const index = processedGroupBy.findIndex((gb) => gb.fieldName === fieldName); |
|
||||
if (index === -1) { |
|
||||
processedGroupBy.push(gb); |
|
||||
} else if (interval) { |
|
||||
const registeredInterval = processedGroupBy[index].interval; |
|
||||
if (rankInterval(registeredInterval) < rankInterval(interval)) { |
|
||||
processedGroupBy.splice(index, 1, gb); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
metaData.groupBy = processedGroupBy; |
|
||||
|
|
||||
metaData.measure = processMeasure(metaData.measure); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* @protected |
|
||||
*/ |
|
||||
async _prepareData() { |
|
||||
const processedDataPoints = this._getProcessedDataPoints(); |
|
||||
this.data = null; |
|
||||
if (this._isValidData(processedDataPoints)) { |
|
||||
this.data = this._getData(processedDataPoints); |
|
||||
} |
|
||||
} |
|
||||
} |
|
@ -1,627 +0,0 @@ |
|||||
/** @odoo-module **/ |
|
||||
|
|
||||
import { _lt } from "@web/core/l10n/translation"; |
|
||||
import { BORDER_WHITE, DEFAULT_BG, getColor, hexToRGBA } from "./colors"; |
|
||||
import { formatFloat } from "@web/fields/formatters"; |
|
||||
import { SEP } from "./graph_model"; |
|
||||
import { sortBy } from "@web/core/utils/arrays"; |
|
||||
import { useAssets } from "@web/core/assets"; |
|
||||
import { useEffect } from "@web/core/utils/hooks"; |
|
||||
|
|
||||
const { Component, hooks } = owl; |
|
||||
const { useRef } = hooks; |
|
||||
|
|
||||
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.canvasRef = useRef("canvas"); |
|
||||
this.containerRef = useRef("container"); |
|
||||
|
|
||||
this.chart = null; |
|
||||
this.tooltip = null; |
|
||||
this.legendTooltip = null; |
|
||||
|
|
||||
useAssets({ jsLibs: ["/web/static/lib/Chart/Chart.js"] }); |
|
||||
|
|
||||
useEffect(() => this.renderChart()); |
|
||||
} |
|
||||
|
|
||||
willUnmount() { |
|
||||
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"); |
|
||||
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.el.style.cursor = ""; |
|
||||
this.removeTooltips(); |
|
||||
if (tooltipModel.opacity === 0 || tooltipModel.dataPoints.length === 0) { |
|
||||
return; |
|
||||
} |
|
||||
if (!disableLinking && mode !== "line") { |
|
||||
this.el.style.cursor = "pointer"; |
|
||||
} |
|
||||
const chartAreaTop = this.chart.chartArea.top; |
|
||||
const viewContentTop = this.el.getBoundingClientRect().top; |
|
||||
const innerHTML = this.env.qweb.renderToString("web.GraphRenderer.CustomTooltip", { |
|
||||
maxWidth: getMaxWidth(this.chart.chartArea), |
|
||||
measure: measures[measure].string, |
|
||||
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 } = this.model.metaData; |
|
||||
const elementOptions = {}; |
|
||||
if (mode === "bar") { |
|
||||
elementOptions.rectangle = { borderWidth: 1 }; |
|
||||
} else if (mode === "line") { |
|
||||
elementOptions.line = { fill: false, 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 } = 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 (data.datasets.length === 1) { |
|
||||
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, |
|
||||
} = 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, |
|
||||
}, |
|
||||
}; |
|
||||
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 id = item.index; |
|
||||
const dataset = data.datasets[item.datasetIndex]; |
|
||||
let label = dataset.trueLabels[id]; |
|
||||
let value = this.formatValue(dataset.data[id], allIntegers); |
|
||||
let boxColor; |
|
||||
if (mode === "pie") { |
|
||||
if (label === NO_DATA) { |
|
||||
value = this.formatValue(0, allIntegers); |
|
||||
} |
|
||||
if (domains.length > 1) { |
|
||||
label = `${dataset.label} / ${label}`; |
|
||||
} |
|
||||
boxColor = dataset.backgroundColor[id]; |
|
||||
} else { |
|
||||
if (groupBy.length > 1 || domains.length > 1) { |
|
||||
label = `${label} / ${dataset.label}`; |
|
||||
} |
|
||||
boxColor = mode === "bar" ? dataset.backgroundColor : dataset.borderColor; |
|
||||
} |
|
||||
items.push({ id, label, value, boxColor }); |
|
||||
} |
|
||||
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.el.getBoundingClientRect().top; |
|
||||
const legendTooltip = Object.assign(document.createElement("div"), { |
|
||||
className: "o_tooltip_legend", |
|
||||
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 = ["model", "onGraphClicked"]; |
|
@ -1,160 +0,0 @@ |
|||||
/** @odoo-module **/ |
|
||||
|
|
||||
import { _lt } from "@web/core/l10n/translation"; |
|
||||
import { registry } from "@web/core/registry"; |
|
||||
import { useService } from "@web/core/utils/hooks"; |
|
||||
import { GroupByMenu } from "@web/search/group_by_menu/group_by_menu"; |
|
||||
import { standardViewProps } from "@web/views/helpers/standard_view_props"; |
|
||||
import { useSetupView } from "@web/views/helpers/view_hook"; |
|
||||
import { Layout } from "@web/views/layout"; |
|
||||
import { useModel } from "@web/views/helpers/model"; |
|
||||
import { GraphArchParser } from "./graph_arch_parser"; |
|
||||
import { GraphModel } from "./graph_model"; |
|
||||
import { GraphRenderer } from "./graph_renderer"; |
|
||||
|
|
||||
const viewRegistry = registry.category("views"); |
|
||||
|
|
||||
const { Component } = owl; |
|
||||
|
|
||||
export class GraphView extends Component { |
|
||||
setup() { |
|
||||
this.actionService = useService("action"); |
|
||||
|
|
||||
let modelParams; |
|
||||
if (this.props.state) { |
|
||||
modelParams = this.props.state.metaData; |
|
||||
} else { |
|
||||
const { arch, fields } = this.props; |
|
||||
const parser = new this.constructor.ArchParser(); |
|
||||
const archInfo = parser.parse(arch, fields); |
|
||||
modelParams = { |
|
||||
additionalMeasures: this.props.additionalMeasures, |
|
||||
disableLinking: Boolean(archInfo.disableLinking), |
|
||||
displayScaleLabels: this.props.displayScaleLabels, |
|
||||
fieldAttrs: archInfo.fieldAttrs, |
|
||||
fields: this.props.fields, |
|
||||
groupBy: archInfo.groupBy, |
|
||||
measure: archInfo.measure || "__count", |
|
||||
mode: archInfo.mode || "bar", |
|
||||
order: archInfo.order || null, |
|
||||
resModel: this.props.resModel, |
|
||||
stacked: "stacked" in archInfo ? archInfo.stacked : true, |
|
||||
title: archInfo.title || this.env._t("Untitled"), |
|
||||
}; |
|
||||
} |
|
||||
|
|
||||
this.model = useModel(this.constructor.Model, modelParams); |
|
||||
|
|
||||
useSetupView({ |
|
||||
getLocalState: () => { |
|
||||
return { metaData: this.model.metaData }; |
|
||||
}, |
|
||||
getContext: () => this.getContext(), |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* @returns {Object} |
|
||||
*/ |
|
||||
getContext() { |
|
||||
// expand context object? change keys?
|
|
||||
const { measure, groupBy, mode } = this.model.metaData; |
|
||||
return { |
|
||||
graph_measure: measure, |
|
||||
graph_mode: mode, |
|
||||
graph_groupbys: groupBy.map((gb) => gb.spec), |
|
||||
}; |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* @param {string} domain the domain of the clicked area |
|
||||
*/ |
|
||||
onGraphClicked(domain) { |
|
||||
const { context, resModel, title } = this.model.metaData; |
|
||||
|
|
||||
const views = {}; |
|
||||
for (const [viewId, viewType] of this.env.config.views || []) { |
|
||||
views[viewType] = viewId; |
|
||||
} |
|
||||
function getView(viewType) { |
|
||||
return [views[viewType] || false, viewType]; |
|
||||
} |
|
||||
const actionViews = [getView("list"), getView("form")]; |
|
||||
|
|
||||
this.actionService.doAction( |
|
||||
{ |
|
||||
context, |
|
||||
domain, |
|
||||
name: title, |
|
||||
res_model: resModel, |
|
||||
target: "current", |
|
||||
type: "ir.actions.act_window", |
|
||||
views: actionViews, |
|
||||
}, |
|
||||
{ |
|
||||
viewType: "list", |
|
||||
} |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* @param {CustomEvent} ev |
|
||||
*/ |
|
||||
onMeasureSelected(ev) { |
|
||||
const { measure } = ev.detail.payload; |
|
||||
this.model.updateMetaData({ measure }); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* @param {"bar"|"line"|"pie"} mode |
|
||||
*/ |
|
||||
onModeSelected(mode) { |
|
||||
this.model.updateMetaData({ mode }); |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* @param {"ASC"|"DESC"} order |
|
||||
*/ |
|
||||
toggleOrder(order) { |
|
||||
const { order: currentOrder } = this.model.metaData; |
|
||||
const nextOrder = currentOrder === order ? null : order; |
|
||||
this.model.updateMetaData({ order: nextOrder }); |
|
||||
} |
|
||||
|
|
||||
toggleStacked() { |
|
||||
const { stacked } = this.model.metaData; |
|
||||
this.model.updateMetaData({ stacked: !stacked }); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
GraphView.template = "web.GraphView"; |
|
||||
GraphView.buttonTemplate = "web.GraphView.Buttons"; |
|
||||
|
|
||||
GraphView.components = { GroupByMenu, Renderer: GraphRenderer, Layout }; |
|
||||
|
|
||||
GraphView.defaultProps = { |
|
||||
additionalMeasures: [], |
|
||||
displayGroupByMenu: false, |
|
||||
displayScaleLabels: true, |
|
||||
}; |
|
||||
|
|
||||
GraphView.props = { |
|
||||
...standardViewProps, |
|
||||
additionalMeasures: { type: Array, elements: String, optional: true }, |
|
||||
displayGroupByMenu: { type: Boolean, optional: true }, |
|
||||
displayScaleLabels: { type: Boolean, optional: true }, |
|
||||
}; |
|
||||
|
|
||||
GraphView.type = "graph"; |
|
||||
|
|
||||
GraphView.display_name = _lt("Graph"); |
|
||||
GraphView.icon = "fa-bar-chart"; |
|
||||
GraphView.multiRecord = true; |
|
||||
|
|
||||
GraphView.Model = GraphModel; |
|
||||
|
|
||||
GraphView.ArchParser = GraphArchParser; |
|
||||
|
|
||||
GraphView.searchMenuTypes = ["filter", "groupBy", "comparison", "favorite"]; |
|
||||
|
|
||||
viewRegistry.add("graph", GraphView); |
|
Loading…
Reference in new issue