/** @odoo-module **/ /* global ChartDataLabels */ import { loadJS } from "@web/core/assets"; import publicWidget from "@web/legacy/js/public/public_widget"; import SESSION_CHART_COLORS from "@survey/js/survey_session_colors"; publicWidget.registry.SurveySessionChart = publicWidget.Widget.extend({ init: function (parent, options) { this._super.apply(this, arguments); this.questionType = options.questionType; this.answersValidity = options.answersValidity; this.hasCorrectAnswers = options.hasCorrectAnswers; this.questionStatistics = this._processQuestionStatistics(options.questionStatistics); this.showInputs = options.showInputs; this.showAnswers = false; }, start: function () { var self = this; return this._super.apply(this, arguments).then(function () { self._setupChart(); }); }, willStart: async function () { await loadJS("/survey/static/src/js/libs/chartjs-plugin-datalabels.js"); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * Updates the chart data using the latest received question user inputs. * * By updating the numbers in the dataset, we take advantage of the Chartjs API * that will automatically add animations to show the new number. * * @param {Object} questionStatistics object containing chart data (counts / labels / ...) * @param {Integer} newAttendeesCount: max height of chart, not used anymore (deprecated) */ updateChart: function (questionStatistics, newAttendeesCount) { if (questionStatistics) { this.questionStatistics = this._processQuestionStatistics(questionStatistics); } if (this.chart) { // only a single dataset for our bar charts var chartData = this.chart.data.datasets[0].data; for (var i = 0; i < chartData.length; i++){ var value = 0; if (this.showInputs) { value = this.questionStatistics[i].count; } this.chart.data.datasets[0].data[i] = value; } this.chart.update(); } }, /** * Toggling this parameter will display or hide the correct and incorrect answers of the current * question directly on the chart. * * @param {Boolean} showAnswers */ setShowAnswers: function (showAnswers) { this.showAnswers = showAnswers; }, /** * Toggling this parameter will display or hide the user inputs of the current question directly * on the chart. * * @param {Boolean} showInputs */ setShowInputs: function (showInputs) { this.showInputs = showInputs; }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @private */ _setupChart: function () { var $canvas = this.$('canvas'); var ctx = $canvas.get(0).getContext('2d'); this.chart = new Chart(ctx, this._buildChartConfiguration()); }, /** * Custom bar chart configuration for our survey session use case. * * Quick summary of enabled features: * - background_color is one of the 10 custom colors from SESSION_CHART_COLORS * (see _getBackgroundColor for details) * - The ticks are bigger and bolded to be able to see them better on a big screen (projector) * - We don't use tooltips to keep it as simple as possible * - We don't set a suggestedMin or Max so that Chart will adapt automatically based on the given data * The '+1' part is a small trick to avoid the datalabels to be clipped in height * - We use a custom 'datalabels' plugin to be able to display the number value on top of the * associated bar of the chart. * This allows the host to discuss results with attendees in a more interactive way. * * @private */ _buildChartConfiguration: function () { return { type: 'bar', data: { labels: this._extractChartLabels(), datasets: [{ backgroundColor: this._getBackgroundColor.bind(this), data: this._extractChartData(), }] }, options: { maintainAspectRatio: false, plugins: { datalabels: { color: this._getLabelColor.bind(this), font: { size: '50', weight: 'bold', }, anchor: 'end', align: 'top', }, legend: { display: false, }, tooltip: { enabled: false, }, }, scales: { y: { ticks: { display: false, }, grid: { display: false } }, x: { ticks: { minRotation: 20, maxRotation: 90, font: { size :"35", weight:"bold" }, color : '#212529', autoSkip: false, }, grid: { drawOnChartArea: false, color: 'rgba(0, 0, 0, 0.2)' } } }, layout: { padding: { left: 0, right: 0, top: 70, bottom: 0 } } }, plugins: [ChartDataLabels, { /** * The way it works is each label is an array of words. * eg.: if we have a chart label: "this is an example of a label" * The library will split it as: ["this is an example", "of a label"] * Each value of the array represents a line of the label. * So for this example above: it will be displayed as: * "this is an examble
of a label", breaking the label in 2 parts and put on 2 lines visually. * * What we do here is rework the labels with our own algorithm to make them fit better in screen space * based on breakpoints based on number of columns to display. * So this example will become: ["this is an", "example of", "a label"] if we have a lot of labels to put in the chart. * Which will be displayed as "this is an
example of
a label" * Obviously, the more labels you have, the more columns, and less screen space is available. * * When the screen space is too small for long words, those long words are split over multiple rows. * At 6 chars per row, the above example becomes ["this", "is an", "examp-", "le of", "a label"] * Which is displayed as "this
is an
examp-
le of
a label" * * We also adapt the font size based on the width available in the chart. * * So we counterbalance multiple times: * - Based on number of columns (i.e. number of survey.question.answer of your current survey.question), * we split the words of every labels to make them display on more rows. * - Based on the width of the chart (which is equivalent to screen width), * we reduce the chart font to be able to fit more characters. * - Based on the longest word present in the labels, we apply a certain ratio with the width of the chart * to get a more accurate font size for the space available. * * @param {Object} chart */ beforeInit: function (chart) { const nbrCol = chart.data.labels.length; const minRatio = 0.4; // Numbers of maximum characters per line to print based on the number of columns and default ratio for the font size // Between 1 and 2 -> 35, 3 and 4 -> 30, 5 and 6 -> 30, ... const charPerLineBreakpoints = [ [1, 2, 35, minRatio], [3, 4, 30, minRatio], [5, 6, 30, 0.45], [7, 8, 30, 0.65], [9, null, 30, 0.7], ]; let charPerLine; let fontRatio; charPerLineBreakpoints.forEach(([lowerBound, upperBound, value, ratio]) => { if (nbrCol >= lowerBound && (upperBound === null || nbrCol <= upperBound)) { charPerLine = value; fontRatio = ratio; } }); // Adapt font size if the number of characters per line is under the maximum if (charPerLine < 35) { const allWords = chart.data.labels.reduce((accumulator, words) => accumulator.concat(' '.concat(words))); const maxWordLength = Math.max(...allWords.split(' ').map((word) => word.length)); fontRatio = maxWordLength > charPerLine ? minRatio : fontRatio; chart.options.scales.x.ticks.font.size = Math.min(parseInt(chart.options.scales.x.ticks.font.size), chart.width * fontRatio / (nbrCol)); } chart.data.labels.forEach(function (label, index, labelsList) { // Split all the words of the label const words = label.split(" "); let resultLines = []; let currentLine = []; for (let i = 0; i < words.length; i++) { // Chop down words that do not fit on a single line, add each part on its own line. let word = words[i]; while (word.length > charPerLine) { resultLines.push(word.slice(0, charPerLine - 1) + '-'); word = word.slice(charPerLine - 1); } currentLine.push(word); // Continue to add words in the line if there is enough space and if there is at least one more word to add const nextWord = i+1 < words.length ? words[i+1] : null; if (nextWord) { const nextLength = currentLine.join(' ').length + nextWord.length; if (nextLength <= charPerLine) { continue; } } // Add the constructed line and reset the variable for the next line const newLabelLine = currentLine.join(' '); resultLines.push(newLabelLine); currentLine = []; } labelsList[index] = resultLines; }); }, }], }; }, /** * Returns the label of the associated survey.question.answer. * * @private */ _extractChartLabels: function () { return this.questionStatistics.map(function (point) { return point.text; }); }, /** * We simply return an array of zeros as initial value. * The chart will update afterwards as attendees add their user inputs. * * @private */ _extractChartData: function () { return this.questionStatistics.map(function () { return 0; }); }, /** * Custom method that returns a color from SESSION_CHART_COLORS. * It loops through the ten values and assign them sequentially. * * We have a special mechanic when the host shows the answers of a question. * Wrong answers are "faded out" using a 0.3 opacity. * * @param {Object} metaData * @param {Integer} metaData.dataIndex the index of the label, matching the index of the answer * in 'this.answersValidity' * @private */ _getBackgroundColor: function (metaData) { var opacity = '0.8'; if (this.showAnswers && this.hasCorrectAnswers) { if (!this._isValidAnswer(metaData.dataIndex)){ opacity = '0.2'; } } // If metaData.dataIndex is greater than SESSION_CHART_COLORS.length, it should start from the beginning var rgb = SESSION_CHART_COLORS[metaData.dataIndex % SESSION_CHART_COLORS.length]; return `rgba(${rgb},${opacity})`; }, /** * Custom method that returns the survey.question.answer label color. * * Break-down of use cases: * - Red if the host is showing answer, and the associated answer is not correct * - Green if the host is showing answer, and the associated answer is correct * - Black in all other cases * * @param {Object} metaData * @param {Integer} metaData.dataIndex the index of the label, matching the index of the answer * in 'this.answersValidity' * @private */ _getLabelColor: function (metaData) { if (this.showAnswers && this.hasCorrectAnswers) { if (this._isValidAnswer(metaData.dataIndex)){ return '#2CBB70'; } else { return '#D9534F'; } } return '#212529'; }, /** * Small helper method that returns the validity of the answer based on its index. * * We need this special handling because of Chartjs data structure. * The library determines the parameters (color/label/...) by only passing the answer 'index' * (and not the id or anything else we can identify). * * @param {Integer} answerIndex * @private */ _isValidAnswer: function (answerIndex) { return this.answersValidity[answerIndex]; }, /** * Special utility method that will process the statistics we receive from the * survey.question#_prepare_statistics method. * * For multiple choice questions, the values we need are stored in a different place. * We simply return the values to make the use of the statistics common for both simple and * multiple choice questions. * * See survey.question#_get_stats_data for more details * * @param {Object} rawStatistics * @private */ _processQuestionStatistics: function (rawStatistics) { if (["multiple_choice", "scale"].includes(this.questionType)) { return rawStatistics[0].values; } return rawStatistics; } }); export default publicWidget.registry.SurveySessionChart;