/**
*
* 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 {Collection} config.collection - The data source collection
* @param {object} [config.record] - Record to persist the view configuration into the db
* @param {string} [config.chartType] - pie, bar, line...
* @param {string} [config.chartValueField] - Field used to display the values
* @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 {number|string} [config.width]
* @param {number|string} [config.height]
* @returns this
*
* ## Generated markup
* ```
* <a-chartview class="a-chartview">
* <div class="chartview-toolbar">
* <!-- Chart view toolbar items -->
* </div>
* <div class="chartview-chart">
* <!-- Embedded chart -->
* </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",
* chartValueField: "Amount",
* group: ["Country"]
* })
*
* myChartView.render()
* ```
*/
constructor() {
super()
}
/**
* Generates a Chart 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.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"
// Build chart skeletton markup
let id = this.id
this.innerHTML = /*html*/
`<div class="chartview">
<div id="chartview-toolbar:${id}" class="chartview-toolbar">
<div id="actions:${id}"></div>
<div id="setup:${id}"></div>
<div id="sort:${id}"></div>
<div id="filter:${id}"></div>
<div id="refresh:${id}"></div>
</div>
<div class="chartview-container">
<div class="chartview-title">${this.name}</div>
<div class="chartview-chart"></div>
</div>
</div>`.removeExtraSpaces()
// Set chart components
this.chartView = this.querySelector(".chartview")
this.chartTitle = this.querySelector(".chartview-title")
this.chartToolbar = this.querySelector(".chartview-toolbar")
this.chartContainer = this.querySelector(".chartview-chart")
this._initChartParams(config)
._initSize(config)
._initSubscriptions()
return this
}
/**
* 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.chartValueField = config.chartValueField || this.record.config.chartValueField
this.chartValueOperation = config.chartValueOperation || this.record.config.chartValueOperation
} else {
this.chartType = config.chartType || this.config.chartType
this.chartValueField = config.chartValueField || this.config.chartValueField
this.chartValueOperation = config.chartValueOperation || "count"
}
// Defaults to the first number field
if (!this.chartValueField) {
let modelNumberFields = this.model.getFieldsByType(["number"])
if (modelNumberFields.length != 0) {
this.chartValueField = modelNumberFields[0].id
}
else {
this.chartValueField = null
}
}
return this
}
/**
* Display the setup window to configure the chart:
* - page 1: choose the chart type
* - page 2: choose the grouping field
* - page 3: choose the value field
*/
showSetupWindow() {
let chartType
let chartGroupingField
let chartValueField
let chartValueOperation
// Page 1: choose the chart type
const page1 = {
id: "chart-setup-1",
items: [
{
type: "html",
html: txtTitleCase("#chart help 1"),
class: "chartview-wizard"
},
{
layout: "vertical",
defaultConfig: {
type: "button",
margin: 10,
height: 80,
iconSize: 40,
iconColor: "var(--blue)",
action: function() {
chartType = this.config.chartType
$("chart-setup-1").highlightButton(chartType)
publish("EVT_CHART_TYPE_CHANGED", chartType)
}
},
items: [
{
text: txtTitleCase("montrer une répartition, avec peu de catégories"),
icon: "fas fa-chart-pie",
chartType: "pie"
},
{
text: txtTitleCase("montrer une répartition, avec beaucoup de catégories"),
icon: "fas fa-chart-bar",
chartType: "bar"
},
{
text: txtTitleCase("montrer une évolution temporelle"),
icon: "fas fa-chart-line",
chartType: "line"
}
]
}
],
methods: {
load: () => {
chartType = this.chartType
$("chart-setup-1").highlightButton(this.chartType)
},
validate: () => {
if (!chartType) {
createNotification("Vous devez choisir un type de graphique")
return false
}
return true
},
highlightButton(chartType) {
const allButtons = this.querySelectorAll("a-button")
allButtons.forEach(button => {
if (button.config.chartType != chartType) {
button.setColor("var(--button-text)")
button.setIconColor("var(--blue)")
button.setBackgroundColor("var(--button-background)")
}
else {
button.setColor("#ffffff")
button.setIconColor("#ffffff")
button.setBackgroundColor("#00aaee")
}
})
}
}
}
// Page 2: choose the grouping field
const page2 = {
id: "chart-setup-2",
items: [
{
id: "title-chart-setup-category",
type: "html",
html: txtTitleCase("Quel champ voulez-vous utiliser pour grouper les données ?"),
class: "chartview-wizard",
subscriptions: {
EVT_CHART_TYPE_CHANGED: function(type) {
if (type == "line") return this.hide()
this.show()
}
}
},
{
id: "title-chart-setup-time",
type: "html",
html: txtTitleCase("Quel champ voulez-vous utiliser pour l'axe temporel ?"),
class: "chartview-wizard",
subscriptions: {
EVT_CHART_TYPE_CHANGED: function(type) {
if (type != "line") return this.hide()
this.show()
}
}
},
{
type: "select",
id: "chart-grouping-field",
multiple: false,
options: this._groupGetModelFields(),
value: this.group,
width: "100%",
maxHeight: () => kiss.screen.current.height - 200,
optionsColor: this.color,
subscriptions: {
EVT_CHART_TYPE_CHANGED: function(type) {
if (type == "line") return this.hide()
this.show()
}
}
},
{
type: "select",
id: "chart-time-field",
multiple: false,
options: this.model.getFieldsAsOptions("date"),
// value: this.timeField,
width: "100%",
maxHeight: () => kiss.screen.current.height - 200,
optionsColor: this.color,
subscriptions: {
EVT_CHART_TYPE_CHANGED: function(type) {
if (type != "line") return this.hide()
this.show()
}
}
},
{
type: "html",
html: txtTitleCase("Quelle opération ?"),
class: "chartview-wizard"
},
{
type: "select",
id: "chart-operation-type",
multiple: false,
options: [
{ value: "count", text: "Compter" },
{ value: "summary", text: "Résumer" },
],
value: this.chartValueOperation,
width: "100%",
maxHeight: () => kiss.screen.current.height - 200,
optionsColor: this.color
},
{
type: "select",
id: "chart-summary-operation",
multiple: false,
options: [
{ value: "sum", text: "Somme" },
{ value: "average", text: "Moyenne" },
],
value: this.chartValueOperation,
width: "100%",
maxHeight: () => kiss.screen.current.height - 200,
optionsColor: this.color
},
{
type: "html",
html: txtTitleCase("Quel champ pour les valeurs du graphique ?"),
class: "chartview-wizard"
},
{
type: "select",
id: "chart-value-field",
multiple: false,
options: this.model.getFieldsAsOptions("number"),
value: this.chartValueField,
width: "100%",
maxHeight: () => kiss.screen.current.height - 200,
optionsColor: this.color
},
],
methods: {
validate: () => {
return true
chartGroupingField = $("chart-grouping-field").getValue()
if (!chartGroupingField) {
createNotification("Vous devez choisir un champ pour regrouper vos données")
return false
}
return true
}
}
}
// Page 3: choose the value field
const page3 = {
id: "chart-setup-3",
items: [
{
type: "html",
html: txtTitleCase("Que voulez-vous utiliser pour les valeurs du graphique ?"),
class: "chartview-wizard"
},
{
type: "select",
id: "chart-value-field",
multiple: false,
options: this.model.getFieldsAsOptions("number"),
value: this.chartValueField,
width: "100%",
maxHeight: () => kiss.screen.current.height - 200,
optionsColor: this.color
},
{
type: "select",
id: "chart-value-operation",
multiple: false,
options: [
{ value: "sum", text: "Somme" },
{ value: "average", text: "Moyenne" },
{ value: "count", text: "Nombre" }
],
value: this.chartValueOperation,
width: "100%",
maxHeight: () => kiss.screen.current.height - 200,
optionsColor: this.color
}
],
methods: {
validate: () => {
chartValueField = $("chart-value-field").getValue()
chartValueOperation = $("chart-value-operation").getValue()
if (!chartValueField) {
createNotification("Vous devez choisir un champ numérique pour les valeurs du graphique")
return false
}
return true
}
}
}
// Build the wizard
createWizardPanel({
title: txtTitleCase("#chart setup"),
icon: "fas fa-cog",
align: "center",
verticalAlign: "center",
draggable: true,
closable: true,
modal: true,
pageValidation: true,
items: [
page1,
page2,
// page3
],
action: async () => {
chartGroupingField = $("chart-grouping-field").getValue()
chartValueField = $("chart-value-field").getValue()
chartValueOperation = $("chart-value-operation").getValue()
// Broadcast the new chart setup
publish("EVT_VIEW_SETUP:" + this.id, {
group: [chartGroupingField],
chartType,
chartValueField,
chartValueOperation
})
}
}).render()
}
/**
*
* CHART METHODS
*
*/
/**
* Load data into the chart.
*
* Remark:
* - rendering time is proportional to the number of cards and visible fields (cards x fields)
* - rendering takes an average of 0.03 millisecond per card on an Intel i7-4790K
*
* @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`)
}
}
/**
* Generic method to refresh / re-render the view
*
* Note: used in dataComponent (parent class) showSearchBar method.
* This method is invoked to refresh the view after a full-text search has been performed
*/
refresh() {
this._render()
}
/**
* Update the chart color (toolbar buttons + modal windows)
*
* @param {string} newColor
*/
async setColor(newColor) {
this.color = newColor
Array.from(this.chartToolbar.children).forEach(item => {
if (item && item.firstChild && item.firstChild.type == "button") item.firstChild.setIconColor(newColor)
})
}
/**
* Show the window just under the sorting button
*/
showSortWindow() {
let sortButton = $("sort:" + this.id)
const box = sortButton.getBoundingClientRect()
super.showSortWindow(box.left, box.top + 40, this.color)
}
/**
* Show the window just under the fields selector button
*/
showFieldsWindow() {
let selectionButton = $("select:" + this.id)
const box = selectionButton.getBoundingClientRect()
super.showFieldsWindow(box.left, box.top + 40, this.color)
}
/**
* Show the window just under the filter button
*/
showFilterWindow() {
super.showFilterWindow(null, null, this.color)
}
/**
* Update the chart size (recomputes its width and height functions)
*/
updateLayout() {
if (this.isConnected) {
this._setWidth()
this._setHeight()
this._render()
}
}
/**
* Initialize chart sizes
*
* @private
* @ignore
* @returns this
*/
_initSize(config) {
if (config.width) {
this._setWidth()
} else {
this.style.width = this.config.width = "100%"
}
if (config.height) {
this._setHeight()
} else {
this.style.height = this.config.height = "100%"
}
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._updateOneAndReload(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))
])
return this
}
/**
* Adjust the component width
*
* @ignore
* @param {(number|string|function)} [width] - The width to set
*/
_setWidth() {
let newWidth = this._computeSize("width")
setTimeout(() => {
this.style.width = newWidth
this.chartView.style.width = this.clientWidth.toString() + "px"
}, 50)
}
/**
* Adjust the components height
*
* @private
* @ignore
* @param {(number|string|function)} [height] - The height to set
*/
_setHeight() {
let newHeight = this._computeSize("height")
this.style.height = this.chartView.style.height = newHeight
}
/**
* Update the chart configuration
*
* @private
* @ignore
* @param {object} newConfig
*/
async _updateConfig(newConfig) {
if (newConfig.hasOwnProperty("group")) this.group = newConfig.group
if (newConfig.hasOwnProperty("chartType")) this.chartType = newConfig.chartType
if (newConfig.hasOwnProperty("chartValueField")) this.chartValueField = newConfig.chartValueField
if (newConfig.hasOwnProperty("chartValueOperation")) this.chartValueOperation = newConfig.chartValueOperation
let currentConfig
if (this.record) {
currentConfig = this.record.config
}
else {
currentConfig = {
chartType: this.chartType,
chartValueField: this.chartValueField,
chartValueOperation: this.chartValueOperation
}
}
let config = Object.assign(currentConfig, newConfig)
await this.updateConfig({
group: this.group,
config
})
}
/**
*
* RENDERING THE CHART
*
*/
/**
* Render the chart
*
* @private
* @ignore
* @returns this
*/
_render() {
/**
* 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("#kanban help")}</div>`
// Destroy the chart if it exists
if (this.chart) {
this.chart.destroy()
this.chart = null
}
return this
} else {
// Show / hide "empty" icon and header
if (this.collection.records.length == "0") {
this.chartContainer.classList.add("chartview-chart-empty")
return this
}
this.chartContainer.classList.remove("chartview-chart-empty")
log("===================")
log(this.collection.records)
log(this.chartValueOperation)
let data = this.collection.records.filter(record => record.$type == "group")
let labels = data.map(record => record.$name)
let datasets = data.map(record => record.$size)
const valueField = this.chartValueField
switch(this.chartValueOperation) {
case "count":
datasets = data.map(record => record.$size)
break
case "sum":
datasets = data.map(record => record[valueField]?.sum || 0)
break
case "average":
datasets = data.map(record => record[valueField]?.avg || 0)
break
}
// datasets = datasets.map((value, index) => {
// return {
// x: labels[index],
// y: value
// }
// })
// Compute the chart width
let width
let height
const w = this.chartContainer.clientWidth
const h = this.chartContainer.clientHeight
log("!!!!!!!!!!!!!!!!!!!!!!")
log(w)
log(h)
const min = Math.min(w, h)
log(min)
if (this.chartType == "pie") {
if (w > h) {
log("w > h")
width = min
height = h
console.log(width, height, h, w)
}
else {
log("w < h")
width = w
height = min
console.log(width, height, h, w)
}
}
else {
width = "100%"
height = "100%"
}
// Get the color of each category
let groupFieldId = this.collection.group[0]
let colors = labels.map(label => {
return this._getCategoryColor(groupFieldId, label)
})
if (this.chart) {
// The chart already exists, we just update it
this.chart.refresh({
chartType: this.chartType,
width,
height,
data: {
labels,
datasets: [{
label: 'Total workload by Customer',
data: datasets,
borderWidth: 1,
backgroundColor: colors
}]
},
options: {
scales: {
x: {
// type: "time",
time: {
unit: "week"
}
},
y: {
beginAtZero: true
}
},
responsive: true,
maintainAspectRatio: true
}
})
}
else {
// The chart doesn't exist, we create it
this.chartContainer.innerHTML = ""
// window.moment.locale("fr")
this.chart = createChart({
target: this.chartContainer,
width,
height,
chartType: this.chartType,
data: {
// labels,
datasets: [{
label: 'Count',
data: datasets,
borderWidth: 1,
backgroundColor: colors
}]
},
options: {
scales: {
x: {
// type: "time",
// time: {
// unit: "month",
// displayFormats: {
// day: "DD/MM/YYYY",
// quarter: "MM YYYY"
// }
// },
// adapters: {
// date: {
// locale: window.dateFnsLocales.fr
// }
// }
},
y: {
beginAtZero: true
}
},
responsive: true,
maintainAspectRatio: true
}
})
this.chart.render()
}
}
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) {
const field = this.model.getField(groupFieldId)
const options = field.options || []
const option = options.find(option => option.value == columnValue)
return (option) ? option.color : kiss.tools.getRandomColor(0, 53)
}
/**
* Render the toolbar
*
* @private
* @ignore
*/
_renderToolbar() {
if (this.isToolbarRendered) return
// Actions button
createButton({
hidden: this.showActions === false,
target: "actions:" + this.id,
tip: txtTitleCase("actions"),
icon: "fas fa-bolt",
iconColor: this.color,
width: 32,
action: () => this._buildActionMenu()
}).render()
// Setup the chart
createButton({
hidden: !this.showSetup,
target: "setup:" + this.id,
tip: txtTitleCase("setup the chart"),
icon: "fas fa-cog",
iconColor: this.color,
width: 32,
action: () => this.showSetupWindow()
}).render()
// Sorting button
createButton({
hidden: !this.canSort,
target: "sort:" + this.id,
tip: txtTitleCase("to sort"),
icon: "fas fa-sort",
iconColor: this.color,
width: 32,
action: () => this.showSortWindow()
}).render()
// Filtering button
createButton({
hidden: !this.canFilter,
target: "filter:" + this.id,
tip: txtTitleCase("to filter"),
icon: "fas fa-filter",
iconColor: this.color,
width: 32,
action: () => this.showFilterWindow()
}).render()
// View refresh button
if (!kiss.screen.isMobile) {
createButton({
target: "refresh:" + this.id,
tip: txtTitleCase("refresh"),
icon: "fas fa-undo-alt",
iconColor: this.color,
width: 32,
events: {
click: () => this.reload()
}
}).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. See [kiss.ui.Chart](kiss.ui.Chart.html)
*
* @param {object} config
* @returns HTMLElement
*/
const createChartView = (config) => document.createElement("a-chartview").init(config)
;
Source