Source

client/ui/data/chartview.js

/** 
 * 
 * The **Chart** derives from [DataComponent](kiss.ui.DataComponent.html).
 * 
 * It's a [powerful chart view](https://kissjs.net/#ui=start&section=chart) with the following features:
 * 
 * @param {object} config
 * @param {string} config.name - The chart title. We use the "name" property instead of "title" to be consistent with all view names.
 * @param {Collection} config.collection - The data source collection
 * @param {boolean} [config.dashboard] - true if the chart is part of a dashboard. Default is false.
 * @param {object} [config.record] - Record to persist the view configuration into the db
 * @param {string} [config.chartType] - bar | line | pie | doughnut
 * @param {string} [config.isTimeSeries] - true if the chart is a time series. It will manage the timeField and timePeriod for X axis.
 * @param {string} [config.categoryField] - Field to group by, if not a time series
 * @param {string} [config.timeField] - Field to use as time axis, if a time series
 * @param {string} [config.timePeriod] - Grouping unit for time axis, if a time series: day, week, month, quarter, year
 * @param {string} [config.operationType] - "count" or "summary"
 * @param {string} [config.summaryOperation] - "sum", "average". Only used if operationType is "summary"
 * @param {string} [config.valueField] - Field to use as value (Y axis)
 * @param {number} [config.precision] - Number of decimal places to show, only for "number" charts. Default is 0.
 * @param {boolean} [config.startAtZero] - true to start the Y axis at zero. Not used for pie & doughnut charts
 * @param {boolean} [config.showLegend] - true to show the legend
 * @param {string} [config.legendPosition] - top, bottom, left, right
 * @param {boolean} [config.showValues] - true to show the values on the chart
 * @param {boolean} [config.showLabels] - true to show the labels on the chart
 * @param {boolean} [config.centerLabels] - true to center labels inside the chart. Default is true.
 * @param {string} [config.labelColor] - Hexa color code for labels inside the chart. Ex: #00aaee
 * @param {string} [config.color] - Hexa color code. Ex: #00aaee
 * @param {boolean} [config.showToolbar] - false to hide the toolbar (default = true)
 * @param {boolean} [config.showActions] - false to hide the custom actions menu (default = true)
 * @param {boolean} [config.canSort] - false to hide the sort button (default = true)
 * @param {boolean} [config.canFilter] - false to hide the filter button (default = true)
 * @param {object[]} [config.actions] - Array of menu actions, where each menu entry is: {text: "abc", icon: "fas fa-check", action: function() {}}
 * @param {boolean} [config.useCDN] - Set to false to use the local version of ChartJS. Default is true.
 * @returns this
 * 
 * ## Generated markup
 * ```
 * <a-chartview class="a-chartview">
 *      <div class="chartview-header">
 *          <div class="chartview-title">
 *              <!-- Chart title -->
 *          </div>
 *          <div class="chartview-toolbar">
 *              <!-- Chart view toolbar items -->
 *          </div>
 *      </div>
 *      <div class="chartview-chart">
 *          <!-- Embedded chart component -->
 *      </div>
 * </a-chartview>
 * ```
 */
kiss.ui.ChartView = class ChartView extends kiss.ui.DataComponent {
	/**
	 * Its a Custom Web Component. Do not use the constructor directly with the **new** keyword.
	 * Instead, use one of the following methods:
	 * 
	 * Create the Web Component and call its **init** method:
	 * ```
	 * const myChartView = document.createElement("a-chartview").init(config)
	 * ```
	 * 
	 * Or use the shorthand for it:
	 * ```
	 * const myChartView = createChartView({
	 *   id: "my-chartview",
	 *   color: "#00aaee",
	 *   collection: kiss.app.collections["opportunity"],
	 *   chartType: "bar",
	 *   categoryField: "Country"
	 * })
	 * 
	 * myChartView.render()
	 * ```
	 */
	constructor() {
		super()
	}

	/**
	 * Generates a Chart view from a JSON config
	 * 
	 * @ignore
	 * @param {object} config - JSON config
	 * @returns {HTMLElement}
	 */
	init(config) {
		// This component must be resized with its parent container
		config.autoSize = true

		// Init the parent DataComponent
		super.init(config)

		// Options
		this.dashboard = (config.dashboard === true)
		this.showToolbar = (config.showToolbar !== false)
		this.showActions = (config.showActions !== false)
		this.showSetup = (config.showSetup !== false)
		this.canSort = (config.canSort !== false)
		this.canFilter = (config.canFilter !== false)
		this.canGroup = (config.canGroup !== false)
		this.actions = config.actions || []
		this.color = config.color || "#00aaee"
		this.useCDN = (config.useCDN === false) ? false : true

		// Build chart view skeletton markup
		let id = this.id
		this.innerHTML = /*html*/
            `<div class="chartview-container">
                <div class="chartview-header">
                    <div class="chartview-title">
                        ${this.name || ""}
                    </div>
                    <div style="flex: 1"></div>
                    <div id="chartview-toolbar:${id}" class="chartview-toolbar">
                        <div id="actions:${id}"></div>
                    </div>
                </div>
                <div class="chartview-chart"></div>
            </div>`.removeExtraSpaces()

		// Set chart view components
		this.header = this.querySelector(".chartview-header")
		this.headerTitle = this.querySelector(".chartview-title")
		this.toolbar = this.querySelector(".chartview-toolbar")
		this.chartContainer = this.querySelector(".chartview-chart")

		this._initChartParams(config)
			._initSubscriptions()
			._initClickEvents()

		return this
	}

	/**
	 * 
	 * CHART METHODS
	 * 
	 */

	/**
	 * Load data into the chart.
	 * 
	 * @ignore
	 */
	async load() {
		try {
			log(`kiss.ui - Chart ${this.id} - Loading collection <${this.collection.id} (changed: ${this.collection.hasChanged})>`)

			// Load records
			await this.collection.find({
				filterSyntax: this.filterSyntax,
				filter: this.filter,
				sortSyntax: this.sortSyntax,
				sort: this.sort,
				group: this.group,
				projection: this.projection,
				groupUnwind: this.groupUnwind
			})            

			// Render the chart view toolbar
			this._renderToolbar()

		} catch (err) {
			log(err)
			log(`kiss.ui - Chart ${this.id} - Couldn't load data properly`)
		}
	}

	/**
	 * Set the chart title
	 * 
	 * @param {string} newTitle 
	 */
	setTitle(newTitle) {
		this.headerTitle.innerHTML = newTitle
	}

	/**
	 * Update the chart color (toolbar buttons + modal windows)
	 * 
	 * @param {string} newColor
	 */
	setColor(newColor) {
		this.color = newColor
		Array.from(this.toolbar.children).forEach(item => {
			if (item && item.firstChild && item.firstChild.type == "button") item.firstChild.setIconColor(newColor)
		})
	}

	/**
	 * Update the chart layout
	 *
	 * @param msg
	 */
	updateLayout(msg) {
		if (this.isConnected) {
			this._render()
		}
	}

	/**
	 * Update the chart size according to its container
	 * 
	 * @private
	 * @ignore
	 * @returns this
	 */
	updateSize() {
		if (!this.chart) return this

		this._initChartSize()
		this.chart.resize(this.chartWidth, this.chartHeight)
		return this
	}    

	/**
	 * Display the setup window to configure the chart
	 */
	showSetupWindow() {
		let _this = this
		let chartType
		let color = this.color

		//
		// CHART TYPE
		//
		const sectionForChartType = {
			id: "chart-setup-type",
			class: "chartview-wizard-section",
			layout: "horizontal",
			defaultConfig: {
				type: "button",
				margin: "0 0.5rem 0 0",
				flex: 1,
				justifyContent: "center",
				height: "6.4rem",
				iconSize: "4rem",
				iconColor: this.color,
				action: function () {
					chartType = this.config.chartType
					$("chart-setup-type").highlightButton(chartType)
					$("chartType").setValue(chartType)

					let data = $("chart-setup").getData()
					publish("EVT_CHART_SETUP_CHANGED", data)
				}
			},
			items: [{
				tip: txtTitleCase("#bar chart"),
				icon: kiss.global.getChartIcon("bar"),
				chartType: "bar"
			},
			{
				tip: txtTitleCase("#line chart"),
				icon: kiss.global.getChartIcon("line"),
				chartType: "line"
			},
			{
				tip: txtTitleCase("#pie chart"),
				icon: kiss.global.getChartIcon("pie"),
				chartType: "pie"
			},
			{
				tip: txtTitleCase("#pie chart"),
				icon: kiss.global.getChartIcon("doughnut"),
				chartType: "doughnut"
			},
			{
				tip: txtTitleCase("#number chart"),
				icon: kiss.global.getChartIcon("number"),
				chartType: "number"
			},
			{
				id: "chartType",
				type: "text",
				value: this.chartType,
				hidden: true
			}
			],
			methods: {
				load: () => {
					chartType = this.chartType
					$("chart-setup-type").highlightButton(this.chartType)
				},
				highlightButton(chartType) {
					const allButtons = this.querySelectorAll("a-button")
					allButtons.forEach(button => {
						if (button.config.chartType != chartType) {
							button.setColor(color)
							button.setIconColor(color)
							button.setBackgroundColor("var(--button-background)")
						} else {
							button.setColor("#ffffff")
							button.setIconColor("#ffffff")
							button.setBackgroundColor(color)
						}
					})
				}
			}
		}

		// 
		// CHART DATA
		// 
		const categoryFields = this.model.getFieldsAsOptions([
			"text",
			"select",
			"selectViewColumn",
			"selectViewColumns",
			"checkbox",
			"directory",
			"rating",
			"icon",
			"color"
		]).filter(field => !field.isFromPlugin) // For now, don't use the fields coming from plugins

		const sectionForChartData = {
			class: "chartview-wizard-section",
			type: "panel",
			title: txtTitleCase("data"),
			headerColor: "var(--body)",
			headerBackgroundColor: "var(--body-background-alt)",
			border: "none",

			defaultConfig: {
				width: "100%",
				labelWidth: "50%",
				fieldWidth: "50%",
				labelPosition: "left"
			},

			items: [
				// TITLE (NAME)
				{
					type: "text",
					id: "name",
					label: txtTitleCase("title"),
					value: this.name || ""
				},                
				// TIME SERIES
				{
					type: "checkbox",
					id: "isTimeSeries",
					label: txtTitleCase("#time series"),
					value: this.isTimeSeries,
					shape: "switch",
					width: "100%",
					subscriptions: {
						EVT_CHART_SETUP_CHANGED: function (data) {
							if (data.chartType == "pie" || data.chartType == "doughnut" || data.chartType == "number") return this.hide()
							this.show()
						}
					},
					events: {
						change: () => publish("EVT_CHART_SETUP_CHANGED", $("chart-setup").getData())
					}
				},
				// CATEGORY FIELD
				{
					type: "select",
					id: "categoryField",
					label: txtTitleCase("#category field"),
					multiple: false,
					options: categoryFields,
					value: this.categoryField,
					maxHeight: () => kiss.screen.current.height - 200,
					optionsColor: this.color,
					subscriptions: {
						EVT_CHART_SETUP_CHANGED: function (data) {
							if (data.isTimeSeries && data.chartType != "pie" && data.chartType != "doughnut") return this.hide()
							if (data.chartType == "number") return this.hide()
							this.show()
						}
					}
				},
				// TIME FIELD
				{
					type: "select",
					id: "timeField",
					label: txtTitleCase("time axis"),
					multiple: false,
					options: this.model.getFieldsAsOptions("date"),
					value: this.timeField,
					autocomplete: "off",
					maxHeight: () => kiss.screen.current.height - 200,
					optionsColor: this.color,
					subscriptions: {
						EVT_CHART_SETUP_CHANGED: function (data) {
							if (data.isTimeSeries && data.chartType != "pie" && data.chartType != "doughnut" && data.chartType != "number") return this.show()
							this.hide()
						}
					}
				},
				// TIME GROUPING UNIT
				{
					type: "select",
					id: "timePeriod",
					label: txtTitleCase("group by"),
					multiple: false,
					options: [
						{
							value: "week",
							label: txtTitleCase("week")
						},
						{
							value: "month",
							label: txtTitleCase("month")
						},
						{
							value: "quarter",
							label: txtTitleCase("quarter")
						},
						{
							value: "year",
							label: txtTitleCase("year")
						}
					],
					value: this.timePeriod || "month",
					maxHeight: () => kiss.screen.current.height - 200,
					optionsColor: this.color,
					autocomplete: "off",
					subscriptions: {
						EVT_CHART_SETUP_CHANGED: function (data) {
							if (data.isTimeSeries && data.chartType != "pie" && data.chartType != "doughnut" && data.chartType != "number") return this.show()
							this.hide()
						}
					}
				},
				// OPERATION TYPE
				{
					type: "select",
					id: "operationType",
					label: txtTitleCase("chart values"),
					multiple: false,
					autocomplete: "off",
					options: [{
						label: txtTitleCase("#count data"),
						value: "count"
					},
					{
						label: txtTitleCase("#summarize data"),
						value: "summary"
					}
					],
					value: this.operationType || "count",
					width: "100%",
					maxHeight: () => kiss.screen.current.height - 200,
					optionsColor: this.color,
					events: {
						change: () => publish("EVT_CHART_SETUP_CHANGED", $("chart-setup").getData())
					}
				},
				// SUMMARY OPERATION
				{
					type: "select",
					id: "summaryOperation",
					label: txtTitleCase("summary operation"),
					multiple: false,
					options: [{
						label: txtTitleCase("sum"),
						value: "sum"
					},
					{
						label: txtTitleCase("average"),
						value: "average"
					}
					],
					value: this.summaryOperation || "sum",
					width: "100%",
					maxHeight: () => kiss.screen.current.height - 200,
					optionsColor: this.color,
					autocomplete: "off",
					subscriptions: {
						EVT_CHART_SETUP_CHANGED: function (data) {
							if (data.operationType != "summary") return this.hide()
							this.show()
						}
					}
				},
				// VALUE FIELD
				{
					type: "select",
					id: "valueField",
					label: txtTitleCase("#summary field"),
					multiple: false,
					options: this.model.getFieldsAsOptions(["number", "slider", "rating"]),
					value: this.valueField,
					autocomplete: "off",
					width: "100%",
					maxHeight: () => kiss.screen.current.height - 200,
					optionsColor: this.color,
					subscriptions: {
						EVT_CHART_SETUP_CHANGED: function (data) {
							if (data.operationType != "summary") return this.hide()
							this.show()
						}
					}
				},

				// Sort & filter
				{
					layout: "horizontal",
					margin: "2rem 0 0 0",
					defaultConfig: {
						type: "button",
						width: "100%",
						labelWidth: "50%",
						fieldWidth: "50%",
						labelPosition: "left",
						iconColor: this.color,
						height: "4rem"
					},                    
					items: [
						{
							type: "button",
							text: txtTitleCase("to sort"),
							icon: "fas fa-sort",
							action: () => this.showSortWindow(),
							margin: "0 0.5rem 0 0"
						},
						{
							type: "button",
							text: txtTitleCase("to filter"),
							icon: "fas fa-filter",
							action: () => this.showFilterWindow()
						}
					]
				}
			]
		}

		//
		// CHART LAYOUT
		// 
		const sectionForChartLayout = {
			class: "chartview-wizard-section",
			type: "panel",
			title: txtTitleCase("layout"),
			headerColor: "var(--body)",
			headerBackgroundColor: "var(--body-background-alt)",
			border: "none",

			defaultConfig: {
				width: "100%",
				labelWidth: "50%",
				fieldWidth: "50%",
				labelPosition: "left"
			},

			items: [
				// SHOW LEGEND
				{
					type: "checkbox",
					id: "showLegend",
					label: txtTitleCase("show legend"),
					shape: "switch",
					value: this.showLegend === false ? false : true,
					subscriptions: {
						EVT_CHART_SETUP_CHANGED: function (data) {
							if (data.chartType != "number") return this.show()
							this.hide()
						}
					},
					events: {
						change: () => publish("EVT_CHART_SETUP_CHANGED", $("chart-setup").getData())
					}
				},
				// LEGEND POSITION
				{
					type: "select",
					id: "legendPosition",
					label: txtTitleCase("legend position"),
					autocomplete: "off",
					options: [{
						value: "top",
						label: txtTitleCase("top")
					},
					{
						value: "bottom",
						label: txtTitleCase("bottom")
					},
					{
						value: "left",
						label: txtTitleCase("left")
					},
					{
						value: "right",
						label: txtTitleCase("right")
					}
					],
					value: this.legendPosition || "top",
					subscriptions: {
						EVT_CHART_SETUP_CHANGED: function (data) {
							if (data.showLegend === false || data.chartType == "number") return this.hide()
							this.show()
						}
					}
				},
				// SHOW VALUES ON CHART
				{
					type: "checkbox",
					id: "showValues",
					label: txtTitleCase("show values on chart"),
					shape: "switch",
					value: this.showValues === false ? false : true,
					subscriptions: {
						EVT_CHART_SETUP_CHANGED: function (data) {
							if (data.chartType == "line" || data.chartType == "number") return this.hide()
							this.show()
						}
					},
					events: {
						change: () => publish("EVT_CHART_SETUP_CHANGED", $("chart-setup").getData())
					}
				},
				// SHOW LABELS ON CHART
				{
					type: "checkbox",
					id: "showLabels",
					label: txtTitleCase("show labels on chart"),
					shape: "switch",
					value: !!this.showLabels,
					subscriptions: {
						EVT_CHART_SETUP_CHANGED: function (data) {
							if (data.chartType == "pie" || data.chartType == "doughnut") return this.show()
							this.hide()
						}
					},
					events: {
						change: () => publish("EVT_CHART_SETUP_CHANGED", $("chart-setup").getData())
					}
				},
				// LABELS POSITION ON CHART
				{
					type: "checkbox",
					id: "centerLabels",
					label: txtTitleCase("#center labels"),
					shape: "switch",
					value: this.centerLabels === false ? false : true,
					subscriptions: {
						EVT_CHART_SETUP_CHANGED: function (data) {
							if (data.chartType == "number") return this.hide()
							else if (
								(data.showValues && (data.chartType != "line")) ||
                                (data.showLabels && (data.chartType == "pie" || data.chartType == "doughnut"))
							) return this.show()
							this.hide()
						}
					}
				},
				// LABELS COLOR ON CHART
				{
					type: "color",
					id: "labelColor",
					label: txtTitleCase("color"),
					value: this.labelColor || "#000000",
					subscriptions: {
						EVT_CHART_SETUP_CHANGED: function (data) {
							if (
								(data.showValues && (data.chartType != "line")) ||
                                (data.showLabels && (data.chartType == "pie" || data.chartType == "doughnut")) ||
                                (data.chartType == "number")
							) return this.show()
							this.hide()
						}
					}
				},
				// START AT ZERO
				{
					type: "checkbox",
					id: "startAtZero",
					label: txtTitleCase("start at zero"),
					value: this.startAtZero === false ? false : true,
					shape: "switch",
					width: "100%",
					subscriptions: {
						EVT_CHART_SETUP_CHANGED: function (data) {
							if (data.chartType == "pie" || data.chartType == "doughnut" || data.chartType == "number") return this.hide()
							this.show()
						}
					}
				},                
				// NUMBER PRECISION
				{
					hidden: true, // Not used yet
					type: "select",
					id: "precision",
					label: txtTitleCase("number style"),
					autocomplete: "off",
					value: this.precision || 0,
					options: [{
						label: "1",
						value: 0
					},
					{
						label: "1.0",
						value: 1
					},
					{
						label: "1.00",
						value: 2
					},
					{
						label: "1.000",
						value: 3
					},
					{
						label: "1.0000",
						value: 4
					},
					{
						label: "1.00000",
						value: 5
					},
					{
						label: "1.000000",
						value: 6
					},
					{
						label: "1.0000000",
						value: 7
					},
					{
						label: "1.00000000",
						value: 8
					}
					],
					optionsColor: this.color
					// subscriptions: {
					//     EVT_CHART_SETUP_CHANGED: function (data) {
					//         if (data.chartType != "number") return this.hide()
					//         this.show()
					//     }
					// }
				},
				// FIELD UNIT
				{
					hidden: true, // Not used yet
					type: "text",
					id: "unit",
					label: txtTitleCase("unit"),
					value: this.unit

					// subscriptions: {
					//     EVT_CHART_SETUP_CHANGED: function (data) {
					//         if (data.chartType != "number") return this.hide()
					//         this.show()
					//     }
					// }
				}
			]
		}

		// 
		// Build the final panel
		// 
		const viewId = this.id
		createPanel({
			id: "chart-setup",
			title: txtTitleCase("setup the chart"),
			icon: "fas fa-cog",
			draggable: true,
			closable: true,
			modal: true,
			backdropFilter: true,
			top: 0,
			left: "calc(100vw - 50rem)",
			width: "50rem",
			height: () => kiss.screen.current.height,
			headerHeight: "4.9rem",
			headerStyle: "flat",
			overflowY: "auto",
			animation: {
				name: "fadeIn",
				speed: "faster"
			},

			items: [
				sectionForChartType,
				sectionForChartData,
				sectionForChartLayout,

				// Save button
				{
					layout: "horizontal",
					overflow: "unset",
					defaultConfig: {
						type: "button",
						flex: 1,
						height: "4rem",
						margin: "0.5rem 1rem"
					},
					items: [{
						type: "button",
						icon: "fas fa-check",
						class: "button-ok",
						text: txtTitleCase("save"),
						action: () => $("chart-setup").save()
					}]
				}
			],

			methods: {
				load() {
					// Allow the different fields to show/hide at startup depending on the chart config
					publish("EVT_CHART_SETUP_CHANGED", this.getData())

					// Focus on the first field
					setTimeout(() => $("name").focus(), 100)
				},
				async save() {
					const {
						name,
						chartType,
						isTimeSeries,
						categoryField,
						timeField,
						timePeriod,
						operationType,
						summaryOperation,
						valueField,
						startAtZero,
						showLegend,
						legendPosition,
						showValues,
						showLabels,
						centerLabels,
						labelColor,
						precision,
						unit
					} = $("chart-setup").getData()

					// Controls...
					if (!chartType ||
                        (!isTimeSeries && !categoryField && chartType != "number") ||
                        (chartType == "pie" && !categoryField) ||
                        (chartType == "doughnut" && !categoryField) ||
                        (isTimeSeries && !timeField) ||
                        (operationType == "summary" && !summaryOperation) ||
                        (operationType == "summary" && !valueField)
					) {
						return createNotification(txtTitleCase("#chart wrong params"))
					}

					// Broadcast the new chart setup
					publish("EVT_VIEW_SETUP:" + viewId, {
						name,
						chartType,
						isTimeSeries,
						categoryField,
						timeField,
						timePeriod,
						operationType,
						summaryOperation,
						valueField,
						startAtZero,
						showLegend,
						legendPosition,
						showValues,
						showLabels,
						centerLabels,
						labelColor,
						precision,
						unit
					})

					// If the chart is part of a dashboard, tell the dashboard to update
					if (_this.dashboard) publish("EVT_DASHBOARD_SETUP", viewId)
				}
			}
		}).render()
	}

	/**
	 * Display the source records of the chart
	 * 
	 * @param {string} [category] - Optional category to filter the records
	 */
	async showRecords(category) {
		// Check if the ACL allows the user to zoom on the chart details
		if (!this._canReadDetails()) return

		const model = this.model
		const tempDatatableId = "chart-data-" + this.id
		const tempCollection = this.collection.clone()
		let newFilter

		if (category) {
			if (!this.isTimeSeries) {
				// Bar, Pie, Doughnut
				const categoryField = this.model.getField(this.categoryField)
				newFilter = {
					type: "group",
					operator: "and",
					filters: [
						this.collection.filter,
						{
							type: "filter",
							fieldId: categoryField.id,
							fieldType: categoryField.type,
							operator: "=",
							value: category
						}
					]
				}
			}
			// else if (this.isTimeSeries) {
			//     // Bar, Line
			//     const timeField = this.model.getField(this.timeField)
			// }
		}
		else {
			if (!this.isTimeSeries) {
				// Bar, Pie, Doughnut
				newFilter = this.collection.filter
			}
			// else if (this.isTimeSeries) {
			//     // Bar, Line
			//     const timeField = this.model.getField(this.timeField)
			// }            
		}

		// Load the records
		await tempCollection.find({
			filterSyntax: "normalized",
			filter: newFilter,
			group: []
		})

		// Create the datatable
		const datatable = createDatatable({
			id: "datatable-" + tempDatatableId,
			type: "datatable",
			collection: tempCollection,
			color: this.color,
			showActions: false,
			showLinks: false,
			canEdit: false,
			canAddField: false,
			canEditField: false,
			canCreateRecord: false,
			canSelect: false,
			autoSize: true
		})       
    
		// Build the panel to show the datatable
		createPanel({
			modal: true,
			closable: true,
			title: "<b>" + model.namePlural + "</b>",
			icon: model.icon,
			headerBackgroundColor: model.color,
			display: "flex",
			layout: "vertical",
			align: "center",
			verticalAlign: "center",
			background: "var(--body-background)",
			padding: 0,
			width: () => "calc(100vw - 2rem)",
			height: () => "calc(100vh - 2rem)",
			autoSize: true,
			border: "none",

			methods: {
				load() {
					setTimeout(() => this.setItems([datatable]), 50)
				}
			}
		}).render()
	}  


	/**
	 * Define the specific chart params:
	 * - chartType: pie, bar, line
	 * - chartValueField: field used to display the values
	 * 
	 * @private
	 * @ignore
	 * @param {object} config - {chartType, chartValueField}
	 * @returns this
	 */
	_initChartParams(config) {
		if (this.record) {
			this.chartType = config.chartType || this.record.config.chartType
			this.isTimeSeries = config.isTimeSeries || this.record.config.isTimeSeries
			this.categoryField = config.categoryField || this.record.config.categoryField
			this.timeField = config.timeField || this.record.config.timeField
			this.timePeriod = config.timePeriod || this.record.config.timePeriod
			this.operationType = config.operationType || this.record.config.operationType
			this.summaryOperation = config.summaryOperation || this.record.config.summaryOperation
			this.valueField = config.valueField || this.record.config.valueField
			this.startAtZero = config.startAtZero || this.record.config.startAtZero
			this.showLegend = config.showLegend || this.record.config.showLegend
			this.legendPosition = config.legendPosition || this.record.config.legendPosition
			this.showValues = config.showValues || this.record.config.showValues
			this.showLabels = config.showLabels || this.record.config.showLabels
			this.centerLabels = config.centerLabels || this.record.config.centerLabels
			this.labelColor = config.labelColor || this.record.config.labelColor
			this.precision = config.precision || this.record.config.precision || 0
			this.unit = config.unit || this.record.config.unit || ""

		} else {
			this.chartType = config.chartType || this.config.chartType
			this.isTimeSeries = config.isTimeSeries || false
			this.categoryField = config.categoryField || this.config.categoryField
			this.timeField = config.timeField || this.config.timeField
			this.timePeriod = config.timePeriod || "month"
			this.operationType = config.operationType || "count"
			this.summaryOperation = config.summaryOperation || "sum"
			this.valueField = config.valueField || this.config.valueField
			this.startAtZero = (config.startAtZero === false) ? false : true
			this.showLegend = (config.showLegend === false) ? false : true
			this.legendPosition = config.legendPosition || "top"
			this.showValues = (config.showValues === false) ? false : true
			this.showLabels = config.showLabels || false
			this.centerLabels = (config.centerLabels === false) ? false : true
			this.labelColor = config.labelColor || "#000000"
			this.precision = config.precision || 0
			this.unit = config.unit || ""
		}

		// Pie and doughnut charts don't support time series
		this.isTimeSeries = this.isTimeSeries && (this.chartType == "line" || this.chartType == "bar")

		// Set category field
		if (this.isTimeSeries) {
			// In time series, we need to sort by time
			this.sort = [{
				[this.timeField]: "asc"
			}]
			this.group = [this.timeField]
		} else {
			if (this.chartType != "number") {
				this.group = [this.categoryField]
			}
			else {
				this.group = []
			}
		}

		// Defaults to the first number field
		if (!this.valueField) {
			let modelNumberFields = this.model.getFieldsByType(["number"])
			if (modelNumberFields.length != 0) {
				this.valueField = modelNumberFields[0].id
			} else {
				this.valueField = null
			}
		}

		return this
	}

	/**
	 * Initialize subscriptions to PubSub
	 * 
	 * @private
	 * @ignore
	 * @returns this
	 */
	_initSubscriptions() {
		super._initSubscriptions()

		const viewModelId = this.modelId.toUpperCase()

		// React to database mutations
		this.subscriptions = this.subscriptions.concat([
			// Local events (not coming from websocket)
			subscribe("EVT_VIEW_SETUP:" + this.id, (msgData) => this._updateConfig(msgData)),

			// React to database mutations
			subscribe("EVT_DB_INSERT:" + viewModelId, (msgData) => this._reloadWhenNeeded(msgData)),
			subscribe("EVT_DB_UPDATE:" + viewModelId, (msgData) => this._reloadWhenNeeded(msgData)),
			subscribe("EVT_DB_DELETE:" + viewModelId, (msgData) => this._reloadWhenNeeded(msgData)),
			subscribe("EVT_DB_INSERT_MANY:" + viewModelId, (msgData) => this._reloadWhenNeeded(msgData, 2000)),
			subscribe("EVT_DB_UPDATE_MANY:" + viewModelId, (msgData) => this._reloadWhenNeeded(msgData, 2000)),
			subscribe("EVT_DB_DELETE_MANY:" + viewModelId, (msgData) => this._reloadWhenNeeded(msgData, 2000)),
			subscribe("EVT_DB_UPDATE_BULK", (msgData) => this._reloadWhenNeeded(msgData, 2000)),
			subscribe("EVT_DB_UPDATE:VIEW", (msgData) => this.reload())
		])

		return this
	}

	/**
	 * Initialize click events
	 * 
	 * @private
	 * @ignore
	 * @returns this
	 */
	_initClickEvents() {
		this.onclick = (e) => {
			const target = e.target
			if (!target.closest(".a-button") && !target.closest(".chartview-number") && target.tagName != "CANVAS") {
				publish("EVT_CHART_CLICKED", {
					chartId: this.id,
					event: e
				})
			}
		}
		return this
	}

	/**
	 * Initialize chart sizes inside component
	 * 
	 * @private
	 * @ignore
	 * @returns this
	 */
	_initChartSize() {
		const VERTICAL_SPACE = 80 // total space when adding toolbar, header, margins, paddings...
		const HORIZONTAL_SPACE = 40
		let width = this.clientWidth - HORIZONTAL_SPACE
		let height = this.clientHeight - VERTICAL_SPACE

		// Maintain max aspect ratio to 2
		// if (width > (2 * height)) width = 2 * height

		this.chartWidth = width
		this.chartHeight = height
		return this
	}

	/**
	 * Update the chart title
	 * 
	 * @private
	 * @ignore
	 */
	_updateTitle(msgData) {
		if (!this.record) return
		if (msgData.id == this.record.id && msgData.data.name) {
			this.setTitle(msgData.data.name)
		}
	}


	/**
	 * Update the chart configuration
	 * 
	 * @private
	 * @ignore
	 * @param {object} newConfig 
	 */
	async _updateConfig(newConfig) {
		let currentConfig
		let finalConfig

		// Evaluate if the new config impacts data or not
		let needsDataReload = false
		if (this.chartType != newConfig.chartType) needsDataReload = true
		if (this.isTimeSeries != newConfig.isTimeSeries) needsDataReload = true
		if (this.isTimeSeries && this.timeField != newConfig.timeField) needsDataReload = true
		if (this.isTimeSeries && this.timePeriod != newConfig.timePeriod) needsDataReload = true
		if (this.categoryField != newConfig.categoryField) needsDataReload = true
		if (this.operationType != newConfig.operationType) needsDataReload = true
		if (this.operationType == "summary" && this.summaryOperation != newConfig.summaryOperation) needsDataReload = true
		if (this.valueField != newConfig.valueField) needsDataReload = true
		if (needsDataReload) this.collection.hasChanged = true
        
		// Get the current config
		if (this.record) {
			currentConfig = this.record.config
		} else {
			currentConfig = {
				chartType: this.chartType,
				isTimeSeries: this.isTimeSeries,
				categoryField: this.categoryField,
				timeField: this.timeField,
				timePeriod: this.timePeriod,
				operationType: this.operationType,
				summaryOperation: this.summaryOperation,
				valueField: this.valueField,
				startAtZero: this.startAtZero,
				showLegend: this.showLegend,
				legendPosition: this.legendPosition,
				showValues: this.showValues,
				showLabels: this.showLabels,
				centerLabels: this.centerLabels,
				labelColor: this.labelColor,
				precision: this.precision,
				unit: this.unit
			}
		}
        
		// Update the chart configuration
		if (newConfig.hasOwnProperty("name")) this.name = newConfig.name
		if (newConfig.hasOwnProperty("chartType")) this.chartType = newConfig.chartType
		if (newConfig.hasOwnProperty("isTimeSeries")) this.isTimeSeries = newConfig.isTimeSeries
		if (newConfig.hasOwnProperty("categoryField")) this.categoryField = newConfig.categoryField
		if (newConfig.hasOwnProperty("timeField")) this.timeField = newConfig.timeField
		if (newConfig.hasOwnProperty("timePeriod")) this.timePeriod = newConfig.timePeriod
		if (newConfig.hasOwnProperty("operationType")) this.operationType = newConfig.operationType
		if (newConfig.hasOwnProperty("summaryOperation")) this.summaryOperation = newConfig.summaryOperation
		if (newConfig.hasOwnProperty("valueField")) this.valueField = newConfig.valueField
		if (newConfig.hasOwnProperty("startAtZero")) this.startAtZero = newConfig.startAtZero
		if (newConfig.hasOwnProperty("showLegend")) this.showLegend = newConfig.showLegend
		if (newConfig.hasOwnProperty("legendPosition")) this.legendPosition = newConfig.legendPosition
		if (newConfig.hasOwnProperty("showValues")) this.showValues = newConfig.showValues
		if (newConfig.hasOwnProperty("showLabels")) this.showLabels = newConfig.showLabels
		if (newConfig.hasOwnProperty("centerLabels")) this.centerLabels = newConfig.centerLabels
		if (newConfig.hasOwnProperty("labelColor")) this.labelColor = newConfig.labelColor
		if (newConfig.hasOwnProperty("precision")) this.precision = newConfig.precision
		if (newConfig.hasOwnProperty("unit")) this.unit = newConfig.unit

		// Apply the new config
		let config = Object.assign(currentConfig, newConfig)
		this.setTitle(this.name)

		// Pie and doughnut charts don't support time series
		this.isTimeSeries = newConfig.isTimeSeries && (newConfig.chartType == "line" || newConfig.chartType == "bar")
        
		if (this.isTimeSeries) {
			// In time series, we need to sort by time
			finalConfig = {
				name: this.name,
				sort: [{
					[config.timeField]: "asc"
				}],
				group: [config.timeField],
				config
			}
		} else {
			if (config.chartType != "number") {
				finalConfig = {
					name: this.name,
					group: [config.categoryField],
					config
				}
			}
			else {
				finalConfig = {
					name: this.name,
					group: [],
					config
				}
			}
		}

		// Apply the new config locally
		if (!this.record) {
			Object.assign(this, finalConfig)
		}

		// Store the new config in the record
		await this.updateConfig(finalConfig, needsDataReload)
	}

	/**
	 * 
	 * RENDERING THE CHART
	 * 
	 */

	/**
	 * Consolidates data for temporal display in Chart.js
	 * 
	 * @param {Array} rawData - Array of raw data [{ x: <date>, y: <value> }]
	 * @param {String} interval - Sampling interval ("day", "week", "month", "quarter", "year")
	 * @param {String} operation - Aggregation operation ("count", "sum", "average")
	 * @returns {Array} - Consolidated dataset for Chart.js
	 */
	_consolidateData(rawData, interval, operation) {
		// Parse a date from various formats (ISO string, Date object, etc.)
		const parseDate = (date) => {
			if (typeof date === "string") {
				// Convert to Date object regardless of the string format
				return new Date(date)
			}
			if (date instanceof Date) {
				return date
			}
			throw new Error("Invalid date format")
		}

		// Format the date into a key based on the specified interval
		const formatKey = (date, interval) => {
			const parsedDate = parseDate(date)
			const year = parsedDate.getFullYear()
			const month = (parsedDate.getMonth() + 1).toString().padStart(2, "0")
			const day = parsedDate.getDate().toString().padStart(2, "0")

			switch (interval) {
			case "day":
				return `${year}-${month}-${day}`

			case "week": {
				const firstDayOfYear = new Date(year, 0, 1)
				const weekNumber = Math.ceil((((parsedDate - firstDayOfYear) / 86400000) + firstDayOfYear.getDay() + 1) / 7)
				return `${year}-W${weekNumber}`
			}
			case "month":
				return `${year}-${month}`

			case "quarter":
				const qMonth = Math.ceil((parsedDate.getMonth() + 1) / 3)
				return `${year}-Q${qMonth}`

			case "year":
				return `${year}`

			default:
				throw new Error("Unsupported interval")
			}
		}

		// Group data by the calculated keys
		const groupedData = rawData.reduce((acc, {
			x,
			y
		}) => {
			const key = formatKey(x, interval)
			if (!acc[key]) {
				acc[key] = {
					total: 0,
					count: 0
				}
			}
			acc[key].total += y
			acc[key].count += 1
			return acc
		}, {})

		// Convert grouped data into a Chart.js-compatible dataset
		return Object.entries(groupedData).map(([key, {
			total,
			count
		}]) => {
			const value = operation === "average" ? total / count : total
			return {
				x: key,
				y: value
			}
		})
	}

	/**
	 * Render the chart / number chart
	 * 
	 * @private
	 * @ignore
	 * @returns this
	 */
	_render() {
		if (this.chartType == "number") return this._renderNumber()
		return this._renderChart()
	}

	/**
	 * Render a simple "number" chart
	 * 
	 * @private
	 * @ignore
	 * @returns this
	 */
	_renderNumber() {
		// Replace the chart by a number
		if (this.chart) {
			this.chart.destroy()
			this.chart = null
		}

		// Reset container
		this.chartContainer.innerHTML = ""
		this.chartContainer.classList.remove("chartview-container-empty")

		// Aggregate data according to the operation
		let operation = (this.operationType == "count") ? "count" : this.summaryOperation
		const valueField = this.valueField
		let total = 0

		// Get the field's unit, if any
		let unit
		let precision
		if (operation != "count") {
			const field = this.model.getField(this.valueField)
			unit = field.unit || ""
			precision = field.precision || 0
		}

		switch (operation) {
		case "count":
			total = this.collection.records.length
			break
		case "sum":
			total = this.collection.records.reduce((acc, record) => acc + record[valueField] || 0, 0)
			break
		case "average":
			total = this.collection.records.reduce((acc, record) => acc + record[valueField] || 0, 0) / this.collection.records.length
			break
		default:
		}

		this.number = createBlock({
			target: this.chartContainer,
			class: "chartview-number",
			backgroundColor: this.labelColor + "20",
			items: [
				{
					type: "html",
					color: this.labelColor,
					html: total.format(precision) + (unit ? ` <span class="chartview-unit">${unit}</span>` : "")
				}
			],
			events: {
				click: () => this.showRecords()
			}
		}).render()
		return this
	}

	/**
	 * Render a chart using Chart.js
	 * 
	 * @private
	 * @ignore
	 * @returns this
	 */    
	_renderChart() {
		/**
		 * datasource
		 * filter
		 * sort
		 * group
		 * title
		 * subtitle
		 * 
		 * number:
		 *  color
		 *  values:
		 *      count
		 *      summary
		 *          sum
		 *          avg
		 *          (+ median, min, max)     
		 * bar:
		 *  x-axis: field
		 *      time:
		 *          unit (week, month, quarter, year)
		 *          format
		 *  y-axis: count
		 *  categories
		 *  values:
		 *      count
		 *      summary
		 *          sum
		 *          avg
		 *          (+ median, min, max)
		 *  color
		 *  size (small medium large)
		 *  orientation
		 *  show records count on chart
		 * 
		 * pie:
		 *  categories (= group)
		 *  values:
		 *      count
		 *      summary
		 *         sum
		 *         avg
		 *         (+ median, min, max)
		 *  size
		 * 
		 */
		if (this.collection.group.length === 0) {
			// No group: can't render a Chart view
			this.chartContainer.classList.remove("chartview-chart-empty")
			this.chartContainer.innerHTML = `<div class="chartview-help">${txtTitleCase("#chart help")}</div>`

			// Destroy the chart if it exists
			if (this.chart) {
				this.chart.destroy()
				this.chart = null
			}
			else if (this.number) {
				this.number.deepDelete()
				this.number = null
			}

			return this

		} else {

			// If there are no records, show the "empty" icon and exit
			if (this.collection.records.length == "0") {
				this.chartContainer.classList.add("chartview-container-empty")
				return this
			}

			// Reset container
			this.chartContainer.classList.remove("chartview-container-empty")

			// Get data from the collection
			let sourceData = this.collection.records.filter(record => record.$type == "group")

			// Normalize data to [{x: "foo", y: 100}, ...]
			let xyData
			let operation = (this.operationType == "count") ? "count" : this.summaryOperation
			const valueField = this.valueField
            
			// TODO: implement valueRenderer compatibility with canvas which does not support HTML tags
			// const categoryField = this.model.getField(this.categoryField)
			// const renderer = categoryField.valueRenderer
			const renderer = false

			switch (operation) {
			case "count":
				if (!renderer) {
					xyData = sourceData.map(rec => {
						return {
							x: "" + rec.$name,
							y: rec.$size
						}
					})
				}
				else {
					xyData = sourceData.map(rec => {
						return {
							x: renderer({
								value: rec.$name,
								record: rec
							}),
							y: rec.$size
						}
					})
				}
				break

			case "sum":
				if (!renderer) {
					xyData = sourceData.map(rec => {
						return {
							x: "" + rec.$name,
							y: rec[valueField]?.sum || 0
						}
					})
				}
				else {
					xyData = sourceData.map(rec => {
						return {
							x: renderer(rec.$name),
							y: rec[valueField]?.sum || 0
						}
					})
				}
				break
                    
			case "average":
				if (!renderer) {
					xyData = sourceData.map(rec => {
						return {
							x: "" + rec.$name,
							y: rec[valueField]?.avg || 0
						}
					})
				}
				else {
					xyData = sourceData.map(rec => {
						return {
							x: renderer(rec.$name),
							y: rec[valueField]?.avg || 0
						}
					})
				}

				break
			default:
			}

			// Consolidate data for time series
			let normalizedData = xyData
			if (this.isTimeSeries) normalizedData = this._consolidateData(xyData, this.timePeriod, operation)

			// Filters out empty categories
			normalizedData = normalizedData.filter(record => record.x !== "" && record.x !== "undefined")

			// Get the color of each category
			let groupFieldId = this.collection.group[0]
			const startIndex = 0//Math.floor(Math.random() * 20)
			let colors = normalizedData.map((record, index) => {
				return this._getCategoryColor(groupFieldId, record.x, startIndex + index)
			})

			// Build the chart data
			const data = {
				datasets: [{
					data: normalizedData,
					borderWidth: 1,
					borderRadius: 5,
					backgroundColor: colors
				}]
			}

			// Plugin to add margin to the legend
			// const legendMargin = {
			//     id: "legendMargin",
			//     afterInit(chart, args, plugins) {
			//         const originalFit = chart.legend.fit
			//         const margin = plugins.margin || 0
			//         chart.legend.fit = function fit() {
			//             if (originalFit) originalFit.call(this)
			//             this.height += margin * 2
			//             this.width += margin * 2
			//             return
			//         }
			//     }
			// }

			// Build chart plugins property
			const currentChartType = this.chartType
			const showLabels = this.showLabels
			const showValues = this.showValues
			const displayLabels = (this.showValues && this.chartType != "line") || (this.showLabels && (this.chartType == "pie" || this.chartType == "doughnut"))
			const legendTitleFieldId = (this.isTimeSeries) ? this.timeField : this.categoryField
			const legendTitleField = (legendTitleFieldId) ? this.model.getField(legendTitleFieldId) : ""
			const legendText = (legendTitleField) ? legendTitleField.label : txtTitleCase("legend")

			let plugins = {

				// Adjust tooltip content depending on the chart type
				tooltip: {
					callbacks: {
						label: function (tooltipItem) {
							const label = tooltipItem.raw.x || ""
							const value = tooltipItem.raw.y || 0
							if (currentChartType == "line" || currentChartType == "bar") return value
							return `${label}: ${value}`
						}
					}
				},

				// Legend setup
				legend: {
					display: this.showLegend,
					position: this.legendPosition,
					title: {
						display: true,
						text: legendText,
						font: {
							weight: "bold",
							size: 14
						}
					},
					labels: {
						boxWidth: 10,
						boxHeight: 10,
						generateLabels: function (chart) {
							const dataset = chart.data.datasets[0]
							return dataset.data.map((record, index) => ({
								datasetIndex: 0,
								text: record.x,
								fillStyle: colors[index],
								textAlign: "left",
								borderRadius: {
									topLeft: 3,
									topRight: 3,
									bottomLeft: 3,
									bottomRight: 3
								}
							}))
						}
					},
					onClick: (event, legendItem, legend) => {
						kiss.context.chartLegendClicked = true
						const category = legendItem.text
						if (this.isTimeSeries) return // Not implemented yet
						this.showRecords(category)
					}
				},

				// Data labels setup (plugin)
				datalabels: {
					display: displayLabels,
					align: "center",
					anchor: (this.centerLabels) ? "center" : "end",
					color: this.labelColor,
					font: {
						weight: "normal"
					},
					formatter: (value) => {
						if (currentChartType == "pie" || currentChartType == "doughnut") {
							if (showLabels && showValues) return value.x + ": " + Math.round(value.y || 0)
							if (showLabels) return value.x
							if (showValues) return Math.round(value.y || 0)
						}
						return Math.round(value.y || 0)
					}
				}
			}

			// Add legend margin if needed
			// if (this.showLegend) {
			//     plugins.legendMargin = {
			//         margin: 20 // Only works for top and left positions at the moment
			//     }
			// }

			// Build chart options property
			let options = {
				scales: {
					y: {
						display: (currentChartType == "pie" || currentChartType == "doughnut") ? false : true,
						beginAtZero: this.startAtZero
					}
				},
				responsive: true,
				maintainAspectRatio: false,
				normalized: true,
				animation: false,
				plugins
			}

			if (this.chartType == "pie" || this.chartType == "doughnut") {
				// Key for pie and doughnut chart
				options.parsing = {
					key: "y"
				}
			} else if (this.isTimeSeries) {
				// X-axis for time series
				options.scales.x = {
					type: "time",
					time: {
						unit: this.timePeriod,
						displayFormats: {
							day: "DD/MM/YYYY",
							week: "YYYY [W]WW",
							year: "YYYY",
							quarter: "[Q]Q-YYYY"
						}
					}
				}

				// Adjust scale according to the time period
				if (this.timePeriod == "week") {
					// options.scales.x.time.parser = "YYYY-[W]WW"
				}
				else if (this.timePeriod == "quarter") {
					options.scales.x.time.parser = "YYYY-[Q]Q"
				}
			}

			// Compute the chart width
			this._initChartSize()

			options.onClick = (event, elements) => {
				if (elements.length > 0) {
					const element = elements[0].element
					const context = element.$context
					const category = context.raw.x
					if (this.isTimeSeries) return // Not implemented yet
					this.showRecords(category)
				}
			}

			if (this.chart) {
				// The chart already exists, we just update it
				this.chart.refresh({
					chartType: this.chartType,
					width: this.chartWidth,
					height: this.chartHeight,
					useDataLabels: displayLabels,
					useMoment: true,
					data,
					options,
					useCDN: this.useCDN
					// plugins: (this.showLegend) ? [legendMargin]: []
				})
			} else {
				// The chart doesn't exist, we create it
				this.chartContainer.innerHTML = ""
				this.chart = createChart({
					target: this.chartContainer,
					chartType: this.chartType,
					width: this.chartWidth,
					height: this.chartHeight,
					useDataLabels: displayLabels,
					useMoment: true,
					data,
					options,
					useCDN: this.useCDN
					// plugins: (this.showLegend) ? [legendMargin]: []
				})

				this.chart.render()
			}

			// Manage when the user clicks on the chart but not a colored element of the chart
			this.chart.onclick = (event) => {
				setTimeout(() => {
					if (kiss.context.chartLegendClicked == true) {
						kiss.context.chartLegendClicked = false
						return
					}
    
					const elements = this.chart.chart.getElementsAtEventForMode(event, "nearest", { intersect: true }, false)
					if (elements.length === 0) {
						publish("EVT_CHART_CLICKED", {
							chartId: this.id,
							event
						})
					}
				}, 50)
			}
		}
		return this        
	}

	/**
	 * Check if the ACL allows the user to zoom on the chart details
	 * 
	 * @private
	 * @ignore
	 * @returns {boolean} true if the user can read the details, false otherwise
	 */
	_canReadDetails() {
		let authenticatedCanReadDetails = true
		let accessReadDetails = ["*"]

		if (this.dashboard) {
			const dashboard = this.closest("a-dashboard")
			if (dashboard.record) {

				authenticatedCanReadDetails = (dashboard.record.authenticatedCanReadDetails === false) ? false : true
				accessReadDetails = (authenticatedCanReadDetails) ? ["*"] : dashboard.record.accessReadDetails
			}
		}
		else if (this.record) {
			authenticatedCanReadDetails = (this.record.authenticatedCanReadDetails === false) ? false : true
			accessReadDetails = (authenticatedCanReadDetails) ? ["*"] : this.record.accessReadDetails
		}

		const userACL = kiss.session.getACL()
		return kiss.tools.intersects(userACL, accessReadDetails)
	}

	/**
	 * Get the color of a category, if any
	 * 
	 * @param {string} groupFieldId 
	 * @param {*} columnValue 
	 * @param index
	 * @returns {string} The color of the category
	 */
	_getCategoryColor(groupFieldId, columnValue, index) {
		const field = this.model.getField(groupFieldId)
		const options = field.options || []
		const randomColor = "#" + kiss.global.palette[(index * 2) % 40]
		if (Array.isArray(options)) {
			const option = options.find(option => option.value == columnValue)
			return (option) ? (option.color || randomColor) : randomColor
		}
		return randomColor
	}

	/**
	 * Render the toolbar
	 * 
	 * @private
	 * @ignore
	 */
	_renderToolbar() {
		if (this.isToolbarRendered) return

		// Actions button
		createButton({
			hidden: this.showActions === false,
			target: "actions:" + this.id,
			icon: "fas fa-chevron-down",
			iconColor: this.color,
			width: "3.2rem",
			border: "none",
			boxShadow: "none",
			action: (event) => {
				createMenu({
					left: event.x - 10,
					top: event.y - 10,
					items: [
						// Setup
						{
							text: txtTitleCase("setup the chart"),
							icon: "fas fa-cog",
							iconColor: this.color,
							action: () => this.showSetupWindow()
						},
						// Refresh
						{
							text: txtTitleCase("refresh"),
							icon: "fas fa-undo-alt",
							iconColor: this.color,
							action: () => this.reload()
						},
						// Download image as PNG
						{   hidden: this.chartType == "number",
							text: txtTitleCase("download image"),
							icon: "fas fa-image",
							iconColor: this.color,
							action: () => {
								if (!this.chart) return
								this.chart.downloadBase64Image("image/png", 1)
							}
						},

						// Delete the chart from a dashboard
						(this.dashboard) ? "-" : "",
						(this.dashboard) ? {
							text: txtTitleCase("delete"),
							icon: "fas fa-trash",
							iconColor: "var(--red)",
							action: () => publish("EVT_DASHBOARD_CHART_DELETED", this.id)
						} : ""
					]
				}).render()
                
			}
		}).render()

		// Flag the toolbar as "rendered", so that the method _renderToolbar() is idempotent
		this.isToolbarRendered = true
	}
}

// Create a Custom Element and add a shortcut to create it
customElements.define("a-chartview", kiss.ui.ChartView)

/**
 * Shorthand to create a new Chart view. See [kiss.ui.ChartView](kiss.ui.ChartView.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createChartView = (config) => document.createElement("a-chartview").init(config)