/** * A chart. Mostly just a wrapper for the Highcharts stuff so the defaults * don't have to be set every single time. */ beestat.component.chart = function() { var self = this; this.addEventListener('render', function() { self.reflow(); }); beestat.component.apply(this, arguments); }; beestat.extend(beestat.component.chart, beestat.component); beestat.component.chart.charts_ = []; /** * Decorate. Calls all the option getters and renders the chart. * * @param {rocket.Elements} parent */ beestat.component.chart.prototype.decorate_ = function(parent) { const self = this; const options = {}; options.credits = this.get_options_credits_(); options.exporting = this.get_options_exporting_(); options.chart = this.get_options_chart_(); options.title = this.get_options_title_(); options.subtitle = this.get_options_subtitle_(); options.legend = this.get_options_legend_(); options.plotOptions = this.get_options_plotOptions_(); options.xAxis = this.get_options_xAxis_(); options.yAxis = this.get_options_yAxis_(); options.series = this.get_options_series_(); options.tooltip = this.get_options_tooltip_(); options.boost = this.get_options_boost_(); options.chart.renderTo = parent[0]; this.chart_ = Highcharts.chart(options); this.addEventListener('render', function() { /** * Clean up old charts. Charts only get added to this array once they * actually get rendered to the page, so it's safe to assume that if they * no longer exist in the DOM they can be destroyed. */ for (let i = beestat.component.chart.charts_.length - 1; i >= 0; i--) { if (document.body.contains(beestat.component.chart.charts_[i].chart_.container) === false) { beestat.component.chart.charts_[i].chart_.destroy(); beestat.component.chart.charts_.splice(i, 1); } } // Add this chart to the charts list. beestat.component.chart.charts_.push(self); }); this.docked_tooltip_container_ = $.createElement('div'); this.docked_tooltip_container_.style({ 'margin-top': (beestat.style.size.gutter / 2) + 'px' }); parent.appendChild(this.docked_tooltip_container_); }; /** * Reset the chart zoom level all the way out. */ beestat.component.chart.prototype.reset_zoom = function() { this.chart_.zoomOut(); }; /** * Export the chart to a PNG. */ beestat.component.chart.prototype.export = function() { this.chart_.exportChartLocal(); }; /** * Get the legend options. * * @return {object} The legend options. */ beestat.component.chart.prototype.get_options_legend_ = function() { return { 'enabled': this.get_options_legend_enabled_(), 'itemStyle': { 'color': '#ecf0f1', 'font-weight': '500' }, 'itemHoverStyle': { 'color': '#bdc3c7' }, 'itemHiddenStyle': { 'color': '#7f8c8d' }, 'labelFormatter': this.get_options_legend_labelFormatter_() }; }; /** * Get the legend labelFormatter options. * * @return {Function} The legend labelFormatter options. */ beestat.component.chart.prototype.get_options_legend_labelFormatter_ = function() { return function() { return beestat.series[this.name].name; }; }; /** * Get the legend enabled options. * * @return {Function} The legend enabled options. */ beestat.component.chart.prototype.get_options_legend_enabled_ = function() { return true; }; /** * Get the plotOptions. * * @return {object} The plotOptions. */ beestat.component.chart.prototype.get_options_plotOptions_ = function() { var self = this; return { 'series': { 'animation': false, 'marker': { 'enabled': false }, 'states': { 'hover': { 'enabled': false }, 'inactive': { 'opacity': 1 } }, 'connectNulls': this.get_options_plotOptions_series_connectNulls_(), 'events': { 'legendItemClick': function() { // Delay the event dispatch so the series is actually toggled to the correct visibility. window.setTimeout(function() { self.dispatchEvent('legend_item_click'); }, 0); } }, 'borderRadius': { 'radius': 0 } }, 'column': { 'pointPadding': 0, 'borderWidth': 0, 'stacking': 'normal', 'dataLabels': { 'enabled': false } } }; }; /** * Get whether or not to connect nulls. * * @return {boolean} Whether or not to connect nulls. */ beestat.component.chart.prototype.get_options_plotOptions_series_connectNulls_ = function() { return false; }; /** * Get the title options. * * @return {object} The title options. */ beestat.component.chart.prototype.get_options_title_ = function() { return { 'text': null }; }; /** * Get the subtitle options * * @return {object} The subtitle options. */ beestat.component.chart.prototype.get_options_subtitle_ = function() { return { 'text': null }; }; /** * Get the chart options. * * @return {object} The chart options. */ beestat.component.chart.prototype.get_options_chart_ = function() { return { 'style': { 'fontFamily': 'Montserrat' }, 'spacing': this.get_options_chart_spacing_(), // For consistent left spacing on charts with no y-axis values 'marginLeft': this.get_options_chart_marginLeft_(), 'marginRight': this.get_options_chart_marginRight_(), 'marginBottom': this.get_options_chart_marginBottom_(), 'zoomType': this.get_options_chart_zoomType_(), 'panning': true, 'panKey': 'ctrl', 'backgroundColor': beestat.style.color.bluegray.base, 'resetZoomButton': { 'theme': { 'style': { 'display': 'none' } } }, 'height': this.get_options_chart_height_(), 'events': this.get_options_chart_events_() }; }; /** * Get the left margin for the chart. * * @return {number} The left margin for the chart. */ beestat.component.chart.prototype.get_options_chart_marginLeft_ = function() { return undefined; }; /** * Get the right margin for the chart. * * @return {number} The right margin for the chart. */ beestat.component.chart.prototype.get_options_chart_marginRight_ = function() { return undefined; }; /** * Get the bottom margin for the chart. * * @return {number} The right margin for the chart. */ beestat.component.chart.prototype.get_options_chart_marginBottom_ = function() { return undefined; }; /** * Get the spacing for the chart. * * @return {number} The spacing for the chart. */ beestat.component.chart.prototype.get_options_chart_spacing_ = function() { return [ (beestat.style.size.gutter / 2), 0, 0, 0 ]; }; /** * Get the events list for the chart. * * @return {number} The events list for the chart. */ beestat.component.chart.prototype.get_options_chart_events_ = function() { return null; }; /** * Get the height of the chart. * * @return {number} The height of the chart. */ beestat.component.chart.prototype.get_options_chart_height_ = function() { return null; }; /** * Get the zoomType option. Return null for no zoom. * * @return {string} The zoom type. */ beestat.component.chart.prototype.get_options_chart_zoomType_ = function() { return 'x'; }; /** * Get the export options. * * @return {object} The export options. */ beestat.component.chart.prototype.get_options_exporting_ = function() { return { 'enabled': false, 'sourceWidth': 980, 'scale': 1, 'filename': this.get_options_exporting_filename_(), 'chartOptions': { 'credits': { 'text': 'beestat.io' }, 'title': { 'align': 'left', 'text': this.get_options_exporting_chartOptions_title_text_(), 'margin': beestat.style.size.gutter, 'style': { 'color': '#fff', 'font-weight': beestat.style.font_weight.bold, 'font-size': beestat.style.font_size.large } }, 'subtitle': { 'align': 'left', 'text': this.get_options_exporting_chartOptions_subtitle_text_(), 'style': { 'color': '#fff', 'font-weight': beestat.style.font_weight.light, 'font-size': beestat.style.font_size.normal } }, 'chart': { 'style': { 'fontFamily': 'Montserrat, Helvetica, Sans-Serif' }, 'spacing': [ beestat.style.size.gutter, beestat.style.size.gutter, beestat.style.size.gutter, beestat.style.size.gutter ], 'events': this.get_options_exporting_chart_events_() } } }; }; /** * Get the exported chart title. * * @return {string} The exported chart title. */ beestat.component.chart.prototype.get_options_exporting_chartOptions_title_text_ = function() { return this.data_.metadata.chart.title; }; /** * Get the exported chart subtitle. * * @return {string} The exported chart subtitle. */ beestat.component.chart.prototype.get_options_exporting_chartOptions_subtitle_text_ = function() { return this.data_.metadata.chart.subtitle; }; /** * Get the exported chart filename. * * @return {string} The exported chart filename. */ beestat.component.chart.prototype.get_options_exporting_filename_ = function() { var title = this.get_options_exporting_chartOptions_title_text_(); var subtitle = this.get_options_exporting_chartOptions_subtitle_text_(); var filename = []; if (title !== null) { filename.push(title); } if (subtitle !== null) { filename.push('-'); filename.push(subtitle); } if (filename.length === 0) { filename.push('beestat'); } return filename.join(' '); }; /** * Get the events list for the chart on export. * * @return {string} The events list for the chart on export. */ beestat.component.chart.prototype.get_options_exporting_chart_events_ = function() { return null; }; /** * Get the credits options. * * @return {boolean} The credits options. */ beestat.component.chart.prototype.get_options_credits_ = function() { return false; }; /** * Get the xAxis options. * * @return {object} The xAxis options. */ beestat.component.chart.prototype.get_options_xAxis_ = function() { var self = this; return { 'categories': this.data_.x, 'lineColor': beestat.style.color.bluegray.light, 'tickLength': 0, 'labels': { 'style': { 'color': beestat.style.color.gray.base, 'font-size': '12px' }, 'formatter': this.get_options_xAxis_labels_formatter_() }, 'crosshair': this.get_options_xAxis_crosshair_(), 'events': { 'afterSetExtremes': function() { self.dispatchEvent('after_set_extremes'); } } }; }; /** * Get the crosshair. * * @return {object} The crosshair. */ beestat.component.chart.prototype.get_options_xAxis_crosshair_ = function() { return { 'width': this.get_options_xAxis_crosshair_width_(), 'zIndex': 100, 'color': 'rgba(255, 255, 255, 0.2)', 'snap': this.get_options_xAxis_crosshair_snap_() }; }; /** * Get the crosshair width. * * @return {object} The crosshair width. */ beestat.component.chart.prototype.get_options_xAxis_crosshair_width_ = function() { return 2; }; /** * Get the crosshair snap. * * @return {object} The crosshair snap. */ beestat.component.chart.prototype.get_options_xAxis_crosshair_snap_ = function() { return false; }; /** * Get the xAxis label formatter options. Needs to be overridden. * * @return {object} The xAxis label formatter options. */ beestat.component.chart.prototype.get_options_xAxis_labels_formatter_ = function() { return null; }; /** * Get the yAxis label formatter options. Needs to be overridden. * * @return {object} The yAxis label formatter options. */ beestat.component.chart.prototype.get_options_yAxis_ = function() { return null; }; /** * Get the series options. Needs to be overridden. * * @return {object} The series options. */ beestat.component.chart.prototype.get_options_series_ = function() { return null; }; /** * Get the tooltip options. * * @return {object} The tooltip options. */ beestat.component.chart.prototype.get_options_tooltip_ = function() { return { 'shared': true, 'useHTML': true, 'borderWidth': 0, 'shadow': false, 'backgroundColor': null, 'followPointer': true, 'hideDelay': 1, 'positioner': this.get_options_tooltip_positioner_(), 'formatter': this.get_options_tooltip_formatter_() }; }; /** * Get the tooltip formatter. Needs to be overridden. * * @return {Function} The tooltip formatter. */ beestat.component.chart.prototype.get_options_tooltip_formatter_ = function() { return null; }; /** * Get the tooltip positioner. Makes sure the tooltip is positioned nicely. * * @return {Function} The tooltip positioner. */ beestat.component.chart.prototype.get_options_tooltip_positioner_ = function() { var self = this; return function(tooltip_width, tooltip_height, point) { return { 'x': self.get_options_tooltip_positioner_x_(tooltip_width, tooltip_height, point), 'y': self.get_options_tooltip_positioner_y_(tooltip_width, tooltip_height, point) }; }; }; /** * Get the tooltip positioner x value. This remembers which way you're moving * the mouse and attempts to position the tooltip out of the way of the * direction of movement. * * @param {number} tooltip_width Tooltip width. * @param {number} tooltip_height Tooltip height. * @param {point} point Highcharts current point. * * @return {number} The tooltip x value. */ beestat.component.chart.prototype.get_options_tooltip_positioner_x_ = function(tooltip_width, tooltip_height, point) { const plot_width = this.chart_.plotWidth; const tooltip_x_history_size = 20; if (this.last_tooltip_x_ === undefined) { this.last_tooltip_x_ = []; } this.last_tooltip_x_.push(point.plotX); if (this.last_tooltip_x_.length > tooltip_x_history_size) { this.last_tooltip_x_.shift(); } const prefer_left = this.last_tooltip_x_[0] <= this.last_tooltip_x_[this.last_tooltip_x_.length - 1]; const fits_on_left = (point.plotX - tooltip_width) > 0; const fits_on_right = (point.plotX + tooltip_width) < plot_width; let x; if (fits_on_left === false && fits_on_right === false) { x = this.chart_.plotLeft; } else { if (prefer_left === true) { if (fits_on_left === true) { x = point.plotX - tooltip_width + this.chart_.plotLeft; } else { x = point.plotX + this.chart_.plotLeft; } } else { if (fits_on_right === true) { x = point.plotX + this.chart_.plotLeft; } else { x = point.plotX - tooltip_width + this.chart_.plotLeft; } } } return x; }; /** * Get the tooltip positioner y value. * * @param {number} tooltip_width Tooltip width. * @param {number} tooltip_height Tooltip height. * @param {point} point Highcharts current point. * * @return {number} The tooltip y value. */ beestat.component.chart.prototype.get_options_tooltip_positioner_y_ = function(tooltip_width, tooltip_height, point) { return 60; }; /** * Get the HTML needed to render a tooltip. * * @param {string} title The tooltip title. * @param {array} sections Data inside the tooltip. * * @return {string} The tooltip HTML. */ beestat.component.chart.prototype.tooltip_formatter_helper_ = function(title, sections) { var tooltip = $.createElement('div') .style({ 'background-color': beestat.style.color.bluegray.dark, 'padding': beestat.style.size.gutter / 2, 'border-radius': beestat.style.size.border_radius, 'box-shadow': '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)' }); if (title !== null) { var title_div = $.createElement('div') .style({ 'font-weight': beestat.style.font_weight.bold, 'font-size': beestat.style.font_size.large, 'margin-bottom': beestat.style.size.gutter / 4, 'color': beestat.style.color.gray.light }) .innerText(title); tooltip.appendChild(title_div); } var table = $.createElement('table') .setAttribute({ 'cellpadding': '0', 'cellspacing': '0' }); tooltip.appendChild(table); sections.forEach(function(section, i) { if (section.length > 0) { section.forEach(function(item) { var tr = $.createElement('tr').style('color', item.color); table.appendChild(tr); var td_label = $.createElement('td') .style({ 'font-weight': beestat.style.font_weight.bold, 'font-size': '12px', 'line-height': '12px' }) .innerText(item.label); tr.appendChild(td_label); var td_value = $.createElement('td').innerText(item.value) .style({ 'padding-left': beestat.style.size.gutter / 4, 'font-size': '12px', 'line-height': '12px' }); tr.appendChild(td_value); }); if (i < sections.length) { var spacer_tr = $.createElement('tr'); table.appendChild(spacer_tr); var spacer_td = $.createElement('td') .style('padding-bottom', beestat.style.size.gutter / 4); spacer_tr.appendChild(spacer_td); } } }); if (this.get_dock_tooltip_() === true) { this.docked_tooltip_container_.innerHTML(tooltip[0].outerHTML); return ''; } else { this.docked_tooltip_container_.innerHTML(''); return tooltip[0].outerHTML; } }; /** * Get the boost options. * * @return {object} The boost options. */ beestat.component.chart.prototype.get_options_boost_ = function() { return { 'enabled': this.get_options_boost_enabled_(), 'useGPUTranslations': true, 'usePreallocated': true }; }; /** * Get the boost enabled option. * * @return {object} The boost enabled option. */ beestat.component.chart.prototype.get_options_boost_enabled_ = function() { return false; }; /** * Get whether or not the tooltip should be docked. * * @return {boolean} */ beestat.component.chart.prototype.get_dock_tooltip_ = function() { return ( beestat.setting('ui.always_dock_tooltips') === true || window.innerWidth < 600 ); }; /** * Get the Highcharts chart object. * * @return {Highcharts} The Highcharts chart object. */ beestat.component.chart.prototype.get_chart = function() { return this.chart_; }; /** * Sync extremes of this chart with extremes of another chart. * * @param {beestat.component.chart} source_chart The source chart. */ beestat.component.chart.prototype.sync_extremes = function(source_chart) { var self = this; source_chart.addEventListener('after_set_extremes', function() { var extremes = source_chart.get_chart().axes[0].getExtremes(); self.get_chart().axes[0].setExtremes( extremes.min, extremes.max, undefined, false ); }); }; /** * Sync crosshair of this chart with crosshair of another chart. * * @param {beestat.component.chart} source_chart The source chart. */ beestat.component.chart.prototype.sync_crosshair = function(source_chart) { var self = this; [ 'mousemove', 'touchmove', 'touchstart' ].forEach(function(event_type) { source_chart.get_chart().container.addEventListener( event_type, function(e) { // https://github.com/highcharts/highcharts/issues/17756 let first_visible_series; for (let i = 0; i < self.get_chart().series.length; i++) { if (self.get_chart().series[i].visible === true) { first_visible_series = self.get_chart().series[i]; break; } } var point = first_visible_series.searchPoint( self.get_chart().pointer.normalize(e), true ); if (point !== undefined) { self.get_chart().tooltip.refresh([point]); self.get_chart().xAxis[0].drawCrosshair(e); } } ); }); // When I leave the source chart, hide the crosshair and tooltip in this chart. source_chart.get_chart().container.addEventListener('mouseout', function() { self.get_chart().xAxis[0].hideCrosshair(); self.get_chart().tooltip.hide(1); }); }; /** * Reflow the chart; useful if the GUI changes and the chart needs resized. * * @link https://api.highcharts.com/class-reference/Highcharts.Chart#reflow */ beestat.component.chart.prototype.reflow = function() { this.chart_.reflow(); }; /** * A generic function to update any element of the chart. Elements can be * enabled and disabled, moved, re-styled, re-formatted etc. * * @param {object} options The options to change. * * @link https://api.highcharts.com/class-reference/Highcharts.Chart#update */ beestat.component.chart.prototype.update = function(options) { this.chart_.update(options); };