/**
*
* The **Chart** derives from [DataComponent](kiss.ui.DataComponent.html).
*
* It's a [powerful chart view](https://kissjs.net/#ui=start§ion=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 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})>`)
// Apply filter, sort, group, projection
// Priority is given to local config, then to the passed collection, then to default
this.collection.filter = this.filter
this.collection.filterSyntax = this.filterSyntax
this.collection.sort = this.sort
this.collection.sortSyntax = this.sortSyntax
this.collection.group = this.group
this.collection.projection = this.projection
this.collection.groupUnwind = this.groupUnwind
// Load records
await this.collection.find()
// Render the chart 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
*/
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"]),
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",
headerBackgroundColor: this.color,
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",
iconColor: "var(--green)",
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) {
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)
// }
tempCollection.filter = newFilter
tempCollection.group = []
}
// Load the records
await tempCollection.find()
// 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,
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._updateTitle(msgData))
])
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
}
/**
* Get the color of a category, if any
*
* @param {string} groupFieldId
* @param {*} columnValue
* @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)
;
Source