import { Controller } from "@hotwired/stimulus"
import { debounce } from "debounce"
import { subscription } from "~/javascripts/store/mixins/subscription"
import {
targetLineValues, metricsInPercentile, axisMeasurementValues, percentage
} from "~/javascripts/dashboard/utils"
import SingleStackedBar from "~/javascripts/dashboard/SingleStackedBar"
/**
* @class Dashboard.CoreVitalsSummaryController
* @classdesc Stimulus controller that populates the mean core vitals score per vital type.
* @extends Controller
**/
export default class extends Controller {
static targets = [
"lcp", "fid", "cls", "lcpLoader", "fidLoader", "clsLoader", "lcpVis", "fidVis", "clsVis"
]
static values = {
loading: String,
storeId: String
}
static classes = [ "success", "warning", "error", "unavailable" ]
/**
* Subscribe to the store.
*
* @instance
* @memberof Dashboard.CoreVitalsSummaryController
**/
connect() {
subscription(this)
this.subscribe()
this.reconnect()
this.resize = debounce(this.resize, 250).bind(this)
}
/**
* Handles a repeated Turbo visit to the dashboard page.
*
* @instance
* @memberof Dashboard.CoreVitalsSummaryController
**/
reconnect() {
if (this.store("selectedDataVizData")) {
this.storeUpdated("selectedDataVizData", this.storeIdValue)
}
}
/**
* Handles a resize of the screen by width but ignores height changes. Deletes the current
* visualisation and the instances of the stacked bar charts so they will be created from scratch again.
*
* @instance
* @memberof Dashboard.CoreVitalsSummaryController
**/
resize() {
if (window.innerWidth === this._windowWidth) return
this._windowWidth = window.innerWidth
this.destroySingleStackedBarDisplay();
["lcp", "fid", "cls"].forEach((context) => {
console.log(this.contextData(context))
this.updateContextDistribution(context, this.contextData(context))
})
}
/**
* Shows the loading indicator for the mean core vital values and hides the stacked bar charts and
* then destroys them so they can be created from scratch again when the new data is fetched from
* the database.
*
* @instance
* @memberof Dashboard.CoreVitalsSummaryController
**/
loadSummary(target, text) {
target.innerHTML = `${text} ${this.loadingValue}`
this.removeAlertColors(target)
this.singleStackedBarDisplay(false)
this.destroySingleStackedBarDisplay()
}
/**
* Remove all of the color classes for the given target
*
* @instance
* @memberof Dashboard.CoreVitalsSummaryController
**/
removeAlertColors(target) {
target.classList.remove(this.successClass, this.warningClass, this.errorClass, this.unavailableClass)
}
/**
* Changes the visibility of the stacked bar charts to either show the data vis or a loading indicator
*
* @instance
* @memberof Dashboard.CoreVitalsSummaryController
**/
singleStackedBarDisplay(hasData=true) {
let dataVis = hasData ? "block" : "none"
let loader = hasData ? "none" : "block"
this.lcpVisTarget.style.display = dataVis
this.fidVisTarget.style.display = dataVis
this.clsVisTarget.style.display = dataVis
this.lcpLoaderTarget.style.display = loader
this.fidLoaderTarget.style.display = loader
this.clsLoaderTarget.style.display = loader
}
/**
* Destroy stacked bar charts for each context and set them to undefined so they will be created from
* scratch again
*
* @instance
* @memberof Dashboard.CoreVitalsSummaryController
**/
destroySingleStackedBarDisplay() {
["lcp", "fid", "cls"].forEach((context) => {
this[`${context}VisTarget`].innerHTML = ""
this[`_singleStackedBar_${context}`] = undefined
})
}
/**
* Update the summary details for the mean values and stacked bar charts
*
* @instance
* @memberof Dashboard.CoreVitalsSummaryController
**/
updateSummary(target, text, context) {
let contextData = this.contextData(context)
this.updateMeanCoreVitalsValue(target, text, context, contextData)
this.singleStackedBarDisplay()
this.updateContextDistribution(context, contextData)
}
/**
* Get the data within the 75th percentile of all current results for the given context
*
* @instance
* @memberof Dashboard.CoreVitalsSummaryController
**/
contextData(context) {
let data = this.store("selectedDataVizData").filter(data => data.metric === context)
return metricsInPercentile(data, "data_float", 0.75)
}
/**
* Sets the text for the mean core vital for the given context and alters to color to indicate whether
* it's good, needs improvement or poor.
*
* @instance
* @memberof Dashboard.CoreVitalsSummaryController
**/
updateMeanCoreVitalsValue(target, text, context, contextData) {
const meanCoreVital = this.calculateMeanCoreVital(contextData)
target.innerHTML = `${text} ${meanCoreVital} ${axisMeasurementValues(context)}`
this.removeAlertColors(target)
target.classList.add(this.alertColor(meanCoreVital, context))
}
/**
* @instance
* @memberof Dashboard.CoreVitalsSummaryController
**/
calculateMeanCoreVital(contextData) {
const totals = contextData.reduce((acc , data) => {
return { count: acc.count + 1, dataFloat: acc.dataFloat + data.data_float }
}, { count: 0, dataFloat: 0 })
return totals.count === 0 ? "N/A" :
Math.round(((totals.dataFloat / totals.count) + Number.EPSILON) * 10000) / 10000
}
/**
* Return the color depending on the time in relation to the KPI
*
* @instance
* @memberof Dashboard.CoreVitalsSummaryController
**/
alertColor(value, context) {
const lineValues = targetLineValues(context)
if (value == "N/A") return this.unavailableClass
if (value <= lineValues.successLineValue) return this.successClass
if (value <= lineValues.failLineValue) return this.warningClass
return this.errorClass
}
/**
* Update the datavisualisation showing the split of good, okay, bad for the context
*
* @instance
* @memberof Dashboard.CoreVitalsSummaryController
**/
updateContextDistribution(context, contextData) {
const grouped = this.coreVitalsGroupingCount(context, contextData)
const data = this.dataForStackedBarChart(grouped)
if(this.isDataVizEmpty(context)) {
this.singleStackedBar(data, context).createDataVis()
} else {
this.singleStackedBar(data, context).updateDataVis(data, context)
}
}
/**
* Get the total count of results plus the number of results within each context.
*
* @instance
* @memberof Dashboard.CoreVitalsSummaryController
**/
coreVitalsGroupingCount(context, contextData) {
let result
const target = targetLineValues(context)
return contextData.reduce((acc , data) => {
if (data.data_float <= target.successLineValue) {
result = "good"
} else if (data.data_float >= target.failLineValue) {
result = "poor"
} else {
result = "needsImprovement"
}
let preUpdate = {
count: acc.count + 1, good: acc.good, needsImprovement: acc.needsImprovement, poor: acc.poor
}
preUpdate[result] += 1
return preUpdate
}, { count: 0, good: 0, needsImprovement: 0, poor: 0 })
}
/**
* Get the percentage for each context. The cumulative value informs the data viz at which x axis value
* the bar should start at. The bar class modifier is used in the CSS to change the bar colour
*
* @instance
* @memberof Dashboard.CoreVitalsSummaryController
**/
dataForStackedBarChart(groupedCounts) {
const good = percentage(groupedCounts.count, groupedCounts.good)
const needsImprovement = percentage(groupedCounts.count, groupedCounts.needsImprovement)
const poor = percentage(groupedCounts.count, groupedCounts.poor)
return [
{ percentage: good, cumulative: 0, barClassModifier: "good" },
{ percentage: needsImprovement, cumulative: good, barClassModifier: "needsImprovement" },
{ percentage: poor, cumulative: (good + needsImprovement), barClassModifier: "poor" }
]
}
/**
* Checks if the visualisation has been created yet
*
* @instance
* @memberof Dashboard.CoreVitalsSummaryController
**/
isDataVizEmpty(context) {
return document.querySelector(`[data-stacked-bar-${context}='wrapper']`).getElementsByTagName("svg").length === 0
}
/**
* Creates a new instance of the SingleStackedBar class if not created, otherwise returns the instance
* of the class
*
* @instance
* @memberof Dashboard.CoreVitalsSummaryController
**/
singleStackedBar(data, context) {
if (this[`_singleStackedBar_${context}`] === undefined) {
this._windowWidth = window.innerWidth
this[`_singleStackedBar_${context}`] = new SingleStackedBar(
data,
this.store("contextSelected"),
`[data-stacked-bar-${context}='wrapper']`
)
}
return this[`_singleStackedBar_${context}`]
}
/**
* Triggered by the store whenever any store data changes.
*
* @instance
* @memberof Dashboard.CoreVitalsSummaryController
**/
storeUpdated(prop, storeId) {
if (this.store("fetchingDataVizData")) {
this.loadSummary(this.lcpTarget, "LCP")
this.loadSummary(this.fidTarget, "FID")
this.loadSummary(this.clsTarget, "CLS")
}
if (prop === "selectedDataVizData" && storeId === this.storeIdValue) {
this.updateSummary(this.lcpTarget, "LCP ...", "lcp")
this.updateSummary(this.fidTarget, "FID ...", "fid")
this.updateSummary(this.clsTarget, "CLS ...", "cls")
}
}
/**
* Unsubscribe from the store
*
* @instance
* @memberof Dashboard.MeanBuildTimesController
**/
disconnect() {
this.unsubscribe()
}
}