From 6a7d84921dec7ac43ed3fb713245d6399ff055c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Hasi=C5=84ski?= Date: Fri, 10 Jun 2022 00:27:28 +0200 Subject: [PATCH 1/8] Add support for annotations Annotations are lines and boxes that show up on line charts with time axis. They can be used to mark events and periods. --- app/assets/javascripts/blazer/application.js | 1 + .../blazer/chartjs-plugin-annotation.js | 928 ++++++++++++++++++ app/controllers/blazer/queries_controller.rb | 1 + app/helpers/blazer/base_helper.rb | 41 + app/views/blazer/queries/run.html.erb | 5 + lib/blazer.rb | 4 + lib/blazer/annotations.rb | 46 + lib/blazer/data_source.rb | 4 + lib/generators/blazer/templates/config.yml.tt | 4 + 9 files changed, 1034 insertions(+) create mode 100644 app/assets/javascripts/blazer/chartjs-plugin-annotation.js create mode 100644 lib/blazer/annotations.rb diff --git a/app/assets/javascripts/blazer/application.js b/app/assets/javascripts/blazer/application.js index 991ee48b2..5a8a8f383 100644 --- a/app/assets/javascripts/blazer/application.js +++ b/app/assets/javascripts/blazer/application.js @@ -12,6 +12,7 @@ //= require ./chartjs-adapter-date-fns.bundle //= require ./chartkick //= require ./mapkick.bundle +//= require ./chartjs-plugin-annotation.js //= require ./ace //= require ./Sortable //= require ./bootstrap diff --git a/app/assets/javascripts/blazer/chartjs-plugin-annotation.js b/app/assets/javascripts/blazer/chartjs-plugin-annotation.js new file mode 100644 index 000000000..bc02840c3 --- /dev/null +++ b/app/assets/javascripts/blazer/chartjs-plugin-annotation.js @@ -0,0 +1,928 @@ +/*! + * chartjs-plugin-annotation.js + * http://chartjs.org/ + * Version: 0.5.7 + * + * Copyright 2016 Evert Timberg + * Released under the MIT license + * https://github.com/chartjs/Chart.Annotation.js/blob/master/LICENSE.md + */ +(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o 0) { + var canvas = chartInstance.chart.canvas; + var eventHandler = events.dispatcher.bind(chartInstance); + events.collapseHoverEvents(watchFor).forEach(function(eventName) { + chartHelpers.addEvent(canvas, eventName, eventHandler); + chartInstance.annotation.onDestroy.push(function() { + chartHelpers.removeEvent(canvas, eventName, eventHandler); + }); + }); + } + }, + destroy: function(chartInstance) { + var deregisterers = chartInstance.annotation.onDestroy; + while (deregisterers.length > 0) { + deregisterers.pop()(); + } + } + }; +}; + +},{"./events.js":4,"./helpers.js":5}],3:[function(require,module,exports){ +module.exports = function(Chart) { + var chartHelpers = Chart.helpers; + + var AnnotationElement = Chart.Element.extend({ + initialize: function() { + this.hidden = false; + this.hovering = false; + this._model = chartHelpers.clone(this._model) || {}; + this.setDataLimits(); + }, + destroy: function() {}, + setDataLimits: function() {}, + configure: function() {}, + inRange: function() {}, + getCenterPoint: function() {}, + getWidth: function() {}, + getHeight: function() {}, + getArea: function() {}, + draw: function() {} + }); + + return AnnotationElement; +}; + +},{}],4:[function(require,module,exports){ +module.exports = function(Chart) { + var chartHelpers = Chart.helpers; + var helpers = require('./helpers.js')(Chart); + + function collapseHoverEvents(events) { + var hover = false; + var filteredEvents = events.filter(function(eventName) { + switch (eventName) { + case 'mouseenter': + case 'mouseover': + case 'mouseout': + case 'mouseleave': + hover = true; + return false; + + default: + return true; + } + }); + if (hover && filteredEvents.indexOf('mousemove') === -1) { + filteredEvents.push('mousemove'); + } + return filteredEvents; + } + + function dispatcher(e) { + var ns = this.annotation; + var elements = helpers.elements(this); + var position = chartHelpers.getRelativePosition(e, this.chart); + var element = helpers.getNearestItems(elements, position); + var events = collapseHoverEvents(ns.options.events); + var dblClickSpeed = ns.options.dblClickSpeed; + var eventHandlers = []; + var eventHandlerName = helpers.getEventHandlerName(e.type); + var options = (element || {}).options; + + // Detect hover events + if (e.type === 'mousemove') { + if (element && !element.hovering) { + // hover started + ['mouseenter', 'mouseover'].forEach(function(eventName) { + var eventHandlerName = helpers.getEventHandlerName(eventName); + var hoverEvent = helpers.createMouseEvent(eventName, e); // recreate the event to match the handler + element.hovering = true; + if (typeof options[eventHandlerName] === 'function') { + eventHandlers.push([ options[eventHandlerName], hoverEvent, element ]); + } + }); + } else if (!element) { + // hover ended + elements.forEach(function(element) { + if (element.hovering) { + element.hovering = false; + var options = element.options; + ['mouseout', 'mouseleave'].forEach(function(eventName) { + var eventHandlerName = helpers.getEventHandlerName(eventName); + var hoverEvent = helpers.createMouseEvent(eventName, e); // recreate the event to match the handler + if (typeof options[eventHandlerName] === 'function') { + eventHandlers.push([ options[eventHandlerName], hoverEvent, element ]); + } + }); + } + }); + } + } + + // Suppress duplicate click events during a double click + // 1. click -> 2. click -> 3. dblclick + // + // 1: wait dblClickSpeed ms, then fire click + // 2: cancel (1) if it is waiting then wait dblClickSpeed ms then fire click, else fire click immediately + // 3: cancel (1) or (2) if waiting, then fire dblclick + if (element && events.indexOf('dblclick') > -1 && typeof options.onDblclick === 'function') { + if (e.type === 'click' && typeof options.onClick === 'function') { + clearTimeout(element.clickTimeout); + element.clickTimeout = setTimeout(function() { + delete element.clickTimeout; + options.onClick.call(element, e); + }, dblClickSpeed); + e.stopImmediatePropagation(); + e.preventDefault(); + return; + } else if (e.type === 'dblclick' && element.clickTimeout) { + clearTimeout(element.clickTimeout); + delete element.clickTimeout; + } + } + + // Dispatch the event to the usual handler, but only if we haven't substituted it + if (element && typeof options[eventHandlerName] === 'function' && eventHandlers.length === 0) { + eventHandlers.push([ options[eventHandlerName], e, element ]); + } + + if (eventHandlers.length > 0) { + e.stopImmediatePropagation(); + e.preventDefault(); + eventHandlers.forEach(function(eventHandler) { + // [handler, event, element] + eventHandler[0].call(eventHandler[2], eventHandler[1]); + }); + } + } + + return { + dispatcher: dispatcher, + collapseHoverEvents: collapseHoverEvents + }; +}; + +},{"./helpers.js":5}],5:[function(require,module,exports){ +function noop() {} + +function elements(chartInstance) { + // Turn the elements object into an array of elements + var elements = chartInstance.annotation.elements; + return Object.keys(elements).map(function(id) { + return elements[id]; + }); +} + +function objectId() { + return Math.random().toString(36).substr(2, 6); +} + +function isValid(rawValue) { + if (rawValue === null || typeof rawValue === 'undefined') { + return false; + } else if (typeof rawValue === 'number') { + return isFinite(rawValue); + } else { + return !!rawValue; + } +} + +function decorate(obj, prop, func) { + var prefix = '$'; + if (!obj[prefix + prop]) { + if (obj[prop]) { + obj[prefix + prop] = obj[prop].bind(obj); + obj[prop] = function() { + var args = [ obj[prefix + prop] ].concat(Array.prototype.slice.call(arguments)); + return func.apply(obj, args); + }; + } else { + obj[prop] = function() { + var args = [ undefined ].concat(Array.prototype.slice.call(arguments)); + return func.apply(obj, args); + }; + } + } +} + +function callEach(fns, method) { + fns.forEach(function(fn) { + (method ? fn[method] : fn)(); + }); +} + +function getEventHandlerName(eventName) { + return 'on' + eventName[0].toUpperCase() + eventName.substring(1); +} + +function createMouseEvent(type, previousEvent) { + try { + return new MouseEvent(type, previousEvent); + } catch (exception) { + try { + var m = document.createEvent('MouseEvent'); + m.initMouseEvent( + type, + previousEvent.canBubble, + previousEvent.cancelable, + previousEvent.view, + previousEvent.detail, + previousEvent.screenX, + previousEvent.screenY, + previousEvent.clientX, + previousEvent.clientY, + previousEvent.ctrlKey, + previousEvent.altKey, + previousEvent.shiftKey, + previousEvent.metaKey, + previousEvent.button, + previousEvent.relatedTarget + ); + return m; + } catch (exception2) { + var e = document.createEvent('Event'); + e.initEvent( + type, + previousEvent.canBubble, + previousEvent.cancelable + ); + return e; + } + } +} + +module.exports = function(Chart) { + var chartHelpers = Chart.helpers; + + function initConfig(config) { + config = chartHelpers.configMerge(Chart.Annotation.defaults, config); + if (chartHelpers.isArray(config.annotations)) { + config.annotations.forEach(function(annotation) { + annotation.label = chartHelpers.configMerge(Chart.Annotation.labelDefaults, annotation.label); + }); + } + return config; + } + + function getScaleLimits(scaleId, annotations, scaleMin, scaleMax) { + var ranges = annotations.filter(function(annotation) { + return !!annotation._model.ranges[scaleId]; + }).map(function(annotation) { + return annotation._model.ranges[scaleId]; + }); + + var min = ranges.map(function(range) { + return Number(range.min); + }).reduce(function(a, b) { + return isFinite(b) && !isNaN(b) && b < a ? b : a; + }, scaleMin); + + var max = ranges.map(function(range) { + return Number(range.max); + }).reduce(function(a, b) { + return isFinite(b) && !isNaN(b) && b > a ? b : a; + }, scaleMax); + + return { + min: min, + max: max + }; + } + + function adjustScaleRange(scale) { + // Adjust the scale range to include annotation values + var range = getScaleLimits(scale.id, elements(scale.chart), scale.min, scale.max); + if (typeof scale.options.ticks.min === 'undefined' && typeof scale.options.ticks.suggestedMin === 'undefined') { + scale.min = range.min; + } + if (typeof scale.options.ticks.max === 'undefined' && typeof scale.options.ticks.suggestedMax === 'undefined') { + scale.max = range.max; + } + if (scale.handleTickRangeOptions) { + scale.handleTickRangeOptions(); + } + } + + function getNearestItems(annotations, position) { + var minDistance = Number.POSITIVE_INFINITY; + + return annotations + .filter(function(element) { + return element.inRange(position.x, position.y); + }) + .reduce(function(nearestItems, element) { + var center = element.getCenterPoint(); + var distance = chartHelpers.distanceBetweenPoints(position, center); + + if (distance < minDistance) { + nearestItems = [element]; + minDistance = distance; + } else if (distance === minDistance) { + // Can have multiple items at the same distance in which case we sort by size + nearestItems.push(element); + } + + return nearestItems; + }, []) + .sort(function(a, b) { + // If there are multiple elements equally close, + // sort them by size, then by index + var sizeA = a.getArea(), sizeB = b.getArea(); + return (sizeA > sizeB || sizeA < sizeB) ? sizeA - sizeB : a._index - b._index; + }) + .slice(0, 1)[0]; // return only the top item + } + + return { + initConfig: initConfig, + elements: elements, + callEach: callEach, + noop: noop, + objectId: objectId, + isValid: isValid, + decorate: decorate, + adjustScaleRange: adjustScaleRange, + getNearestItems: getNearestItems, + getEventHandlerName: getEventHandlerName, + createMouseEvent: createMouseEvent + }; +}; + + +},{}],6:[function(require,module,exports){ +// Get the chart variable +var Chart = require('chart.js'); +Chart = typeof(Chart) === 'function' ? Chart : window.Chart; + +// Configure plugin namespace +Chart.Annotation = Chart.Annotation || {}; + +Chart.Annotation.drawTimeOptions = { + afterDraw: 'afterDraw', + afterDatasetsDraw: 'afterDatasetsDraw', + beforeDatasetsDraw: 'beforeDatasetsDraw' +}; + +Chart.Annotation.defaults = { + drawTime: 'afterDatasetsDraw', + dblClickSpeed: 350, // ms + events: [], + annotations: [] +}; + +Chart.Annotation.labelDefaults = { + backgroundColor: 'rgba(0,0,0,0.8)', + fontFamily: Chart.defaults.global.defaultFontFamily, + fontSize: Chart.defaults.global.defaultFontSize, + fontStyle: 'bold', + fontColor: '#fff', + xPadding: 6, + yPadding: 6, + cornerRadius: 6, + position: 'center', + xAdjust: 0, + yAdjust: 0, + enabled: false, + content: null +}; + +Chart.Annotation.Element = require('./element.js')(Chart); + +Chart.Annotation.types = { + line: require('./types/line.js')(Chart), + box: require('./types/box.js')(Chart) +}; + +var annotationPlugin = require('./annotation.js')(Chart); + +module.exports = annotationPlugin; +Chart.pluginService.register(annotationPlugin); + +},{"./annotation.js":2,"./element.js":3,"./types/box.js":7,"./types/line.js":8,"chart.js":1}],7:[function(require,module,exports){ +// Box Annotation implementation +module.exports = function(Chart) { + var helpers = require('../helpers.js')(Chart); + + var BoxAnnotation = Chart.Annotation.Element.extend({ + setDataLimits: function() { + var model = this._model; + var options = this.options; + var chartInstance = this.chartInstance; + + var xScale = chartInstance.scales[options.xScaleID]; + var yScale = chartInstance.scales[options.yScaleID]; + var chartArea = chartInstance.chartArea; + + // Set the data range for this annotation + model.ranges = {}; + + if (!chartArea) { + return; + } + + var min = 0; + var max = 0; + + if (xScale) { + min = helpers.isValid(options.xMin) ? options.xMin : xScale.getPixelForValue(chartArea.left); + max = helpers.isValid(options.xMax) ? options.xMax : xScale.getPixelForValue(chartArea.right); + + model.ranges[options.xScaleID] = { + min: Math.min(min, max), + max: Math.max(min, max) + }; + } + + if (yScale) { + min = helpers.isValid(options.yMin) ? options.yMin : yScale.getPixelForValue(chartArea.bottom); + max = helpers.isValid(options.yMax) ? options.yMax : yScale.getPixelForValue(chartArea.top); + + model.ranges[options.yScaleID] = { + min: Math.min(min, max), + max: Math.max(min, max) + }; + } + }, + configure: function() { + var model = this._model; + var options = this.options; + var chartInstance = this.chartInstance; + + var xScale = chartInstance.scales[options.xScaleID]; + var yScale = chartInstance.scales[options.yScaleID]; + var chartArea = chartInstance.chartArea; + + // clip annotations to the chart area + model.clip = { + x1: chartArea.left, + x2: chartArea.right, + y1: chartArea.top, + y2: chartArea.bottom + }; + + var left = chartArea.left, + top = chartArea.top, + right = chartArea.right, + bottom = chartArea.bottom; + + var min, max; + + if (xScale) { + min = helpers.isValid(options.xMin) ? xScale.getPixelForValue(options.xMin) : chartArea.left; + max = helpers.isValid(options.xMax) ? xScale.getPixelForValue(options.xMax) : chartArea.right; + left = Math.min(min, max); + right = Math.max(min, max); + } + + if (yScale) { + min = helpers.isValid(options.yMin) ? yScale.getPixelForValue(options.yMin) : chartArea.bottom; + max = helpers.isValid(options.yMax) ? yScale.getPixelForValue(options.yMax) : chartArea.top; + top = Math.min(min, max); + bottom = Math.max(min, max); + } + + // Ensure model has rect coordinates + model.left = left; + model.top = top; + model.right = right; + model.bottom = bottom; + + // Stylistic options + model.borderColor = options.borderColor; + model.borderWidth = options.borderWidth; + model.backgroundColor = options.backgroundColor; + }, + inRange: function(mouseX, mouseY) { + var model = this._model; + return model && + mouseX >= model.left && + mouseX <= model.right && + mouseY >= model.top && + mouseY <= model.bottom; + }, + getCenterPoint: function() { + var model = this._model; + return { + x: (model.right + model.left) / 2, + y: (model.bottom + model.top) / 2 + }; + }, + getWidth: function() { + var model = this._model; + return Math.abs(model.right - model.left); + }, + getHeight: function() { + var model = this._model; + return Math.abs(model.bottom - model.top); + }, + getArea: function() { + return this.getWidth() * this.getHeight(); + }, + draw: function() { + var view = this._view; + var ctx = this.chartInstance.chart.ctx; + + ctx.save(); + + // Canvas setup + ctx.beginPath(); + ctx.rect(view.clip.x1, view.clip.y1, view.clip.x2 - view.clip.x1, view.clip.y2 - view.clip.y1); + ctx.clip(); + + ctx.lineWidth = view.borderWidth; + ctx.strokeStyle = view.borderColor; + ctx.fillStyle = view.backgroundColor; + + // Draw + var width = view.right - view.left, + height = view.bottom - view.top; + ctx.fillRect(view.left, view.top, width, height); + ctx.strokeRect(view.left, view.top, width, height); + + ctx.restore(); + } + }); + + return BoxAnnotation; +}; + +},{"../helpers.js":5}],8:[function(require,module,exports){ +// Line Annotation implementation +module.exports = function(Chart) { + var chartHelpers = Chart.helpers; + var helpers = require('../helpers.js')(Chart); + + var horizontalKeyword = 'horizontal'; + var verticalKeyword = 'vertical'; + + var LineAnnotation = Chart.Annotation.Element.extend({ + setDataLimits: function() { + var model = this._model; + var options = this.options; + + // Set the data range for this annotation + model.ranges = {}; + model.ranges[options.scaleID] = { + min: options.value, + max: options.endValue || options.value + }; + }, + configure: function() { + var model = this._model; + var options = this.options; + var chartInstance = this.chartInstance; + var ctx = chartInstance.chart.ctx; + + var scale = chartInstance.scales[options.scaleID]; + var pixel, endPixel; + if (scale) { + pixel = helpers.isValid(options.value) ? scale.getPixelForValue(options.value) : NaN; + endPixel = helpers.isValid(options.endValue) ? scale.getPixelForValue(options.endValue) : pixel; + } + + if (isNaN(pixel)) { + return; + } + + var chartArea = chartInstance.chartArea; + + // clip annotations to the chart area + model.clip = { + x1: chartArea.left, + x2: chartArea.right, + y1: chartArea.top, + y2: chartArea.bottom + }; + + if (this.options.mode == horizontalKeyword) { + model.x1 = chartArea.left; + model.x2 = chartArea.right; + model.y1 = pixel; + model.y2 = endPixel; + } else { + model.y1 = chartArea.top; + model.y2 = chartArea.bottom; + model.x1 = pixel; + model.x2 = endPixel; + } + + model.line = new LineFunction(model); + model.mode = options.mode; + + // Figure out the label: + model.labelBackgroundColor = options.label.backgroundColor; + model.labelFontFamily = options.label.fontFamily; + model.labelFontSize = options.label.fontSize; + model.labelFontStyle = options.label.fontStyle; + model.labelFontColor = options.label.fontColor; + model.labelXPadding = options.label.xPadding; + model.labelYPadding = options.label.yPadding; + model.labelCornerRadius = options.label.cornerRadius; + model.labelPosition = options.label.position; + model.labelXAdjust = options.label.xAdjust; + model.labelYAdjust = options.label.yAdjust; + model.labelEnabled = options.label.enabled; + model.labelContent = options.label.content; + + ctx.font = chartHelpers.fontString(model.labelFontSize, model.labelFontStyle, model.labelFontFamily); + var textWidth = ctx.measureText(model.labelContent).width; + var textHeight = ctx.measureText('M').width; + var labelPosition = calculateLabelPosition(model, textWidth, textHeight, model.labelXPadding, model.labelYPadding); + model.labelX = labelPosition.x - model.labelXPadding; + model.labelY = labelPosition.y - model.labelYPadding; + model.labelWidth = textWidth + (2 * model.labelXPadding); + model.labelHeight = textHeight + (2 * model.labelYPadding); + + model.borderColor = options.borderColor; + model.borderWidth = options.borderWidth; + model.borderDash = options.borderDash || []; + model.borderDashOffset = options.borderDashOffset || 0; + }, + inRange: function(mouseX, mouseY) { + var model = this._model; + + return ( + // On the line + model.line && + model.line.intersects(mouseX, mouseY, this.getHeight()) + ) || ( + // On the label + model.labelEnabled && + model.labelContent && + mouseX >= model.labelX && + mouseX <= model.labelX + model.labelWidth && + mouseY >= model.labelY && + mouseY <= model.labelY + model.labelHeight + ); + }, + getCenterPoint: function() { + return { + x: (this._model.x2 + this._model.x1) / 2, + y: (this._model.y2 + this._model.y1) / 2 + }; + }, + getWidth: function() { + return Math.abs(this._model.right - this._model.left); + }, + getHeight: function() { + return this._model.borderWidth || 1; + }, + getArea: function() { + return Math.sqrt(Math.pow(this.getWidth(), 2) + Math.pow(this.getHeight(), 2)); + }, + draw: function() { + var view = this._view; + var ctx = this.chartInstance.chart.ctx; + + if (!view.clip) { + return; + } + + ctx.save(); + + // Canvas setup + ctx.beginPath(); + ctx.rect(view.clip.x1, view.clip.y1, view.clip.x2 - view.clip.x1, view.clip.y2 - view.clip.y1); + ctx.clip(); + + ctx.lineWidth = view.borderWidth; + ctx.strokeStyle = view.borderColor; + + if (ctx.setLineDash) { + ctx.setLineDash(view.borderDash); + } + ctx.lineDashOffset = view.borderDashOffset; + + // Draw + ctx.beginPath(); + ctx.moveTo(view.x1, view.y1); + ctx.lineTo(view.x2, view.y2); + ctx.stroke(); + + if (view.labelEnabled && view.labelContent) { + ctx.beginPath(); + ctx.rect(view.clip.x1, view.clip.y1, view.clip.x2 - view.clip.x1, view.clip.y2 - view.clip.y1); + ctx.clip(); + + ctx.fillStyle = view.labelBackgroundColor; + // Draw the tooltip + chartHelpers.drawRoundedRectangle( + ctx, + view.labelX, // x + view.labelY, // y + view.labelWidth, // width + view.labelHeight, // height + view.labelCornerRadius // radius + ); + ctx.fill(); + + // Draw the text + ctx.font = chartHelpers.fontString( + view.labelFontSize, + view.labelFontStyle, + view.labelFontFamily + ); + ctx.fillStyle = view.labelFontColor; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText( + view.labelContent, + view.labelX + (view.labelWidth / 2), + view.labelY + (view.labelHeight / 2) + ); + } + + ctx.restore(); + } + }); + + function LineFunction(view) { + // Describe the line in slope-intercept form (y = mx + b). + // Note that the axes are rotated 90° CCW, which causes the + // x- and y-axes to be swapped. + var m = (view.x2 - view.x1) / (view.y2 - view.y1); + var b = view.x1 || 0; + + this.m = m; + this.b = b; + + this.getX = function(y) { + // Coordinates are relative to the origin of the canvas + return m * (y - view.y1) + b; + }; + + this.getY = function(x) { + return ((x - b) / m) + view.y1; + }; + + this.intersects = function(x, y, epsilon) { + epsilon = epsilon || 0.001; + var dy = this.getY(x), + dx = this.getX(y); + return ( + (!isFinite(dy) || Math.abs(y - dy) < epsilon) && + (!isFinite(dx) || Math.abs(x - dx) < epsilon) + ); + }; + } + + function calculateLabelPosition(view, width, height, padWidth, padHeight) { + var line = view.line; + var ret = {}, xa = 0, ya = 0; + + switch (true) { + // top align + case view.mode == verticalKeyword && view.labelPosition == "top": + ya = padHeight + view.labelYAdjust; + xa = (width / 2) + view.labelXAdjust; + ret.y = view.y1 + ya; + ret.x = (isFinite(line.m) ? line.getX(ret.y) : view.x1) - xa; + break; + + // bottom align + case view.mode == verticalKeyword && view.labelPosition == "bottom": + ya = height + padHeight + view.labelYAdjust; + xa = (width / 2) + view.labelXAdjust; + ret.y = view.y2 - ya; + ret.x = (isFinite(line.m) ? line.getX(ret.y) : view.x1) - xa; + break; + + // left align + case view.mode == horizontalKeyword && view.labelPosition == "left": + xa = padWidth + view.labelXAdjust; + ya = -(height / 2) + view.labelYAdjust; + ret.x = view.x1 + xa; + ret.y = line.getY(ret.x) + ya; + break; + + // right align + case view.mode == horizontalKeyword && view.labelPosition == "right": + xa = width + padWidth + view.labelXAdjust; + ya = -(height / 2) + view.labelYAdjust; + ret.x = view.x2 - xa; + ret.y = line.getY(ret.x) + ya; + break; + + // center align + default: + ret.x = ((view.x1 + view.x2 - width) / 2) + view.labelXAdjust; + ret.y = ((view.y1 + view.y2 - height) / 2) + view.labelYAdjust; + } + + return ret; + } + + return LineAnnotation; +}; + +},{"../helpers.js":5}]},{},[6]); diff --git a/app/controllers/blazer/queries_controller.rb b/app/controllers/blazer/queries_controller.rb index ce0130d79..45a205eb6 100644 --- a/app/controllers/blazer/queries_controller.rb +++ b/app/controllers/blazer/queries_controller.rb @@ -258,6 +258,7 @@ def render_run @linked_columns = @data_source.linked_columns @markers = [] + @annotations = Blazer::Annotations.new(@data_source.annotations).call(@result) @geojson = [] set_map_data if Blazer.maps? diff --git a/app/helpers/blazer/base_helper.rb b/app/helpers/blazer/base_helper.rb index e5406122c..97e43d4a8 100644 --- a/app/helpers/blazer/base_helper.rb +++ b/app/helpers/blazer/base_helper.rb @@ -35,5 +35,46 @@ def blazer_js_var(name, value) def blazer_series_name(k) k.nil? ? "null" : k.to_s end + + def blazer_format_annotations(annotations) + return [] unless annotations.is_a?(Array) + sorted = annotations.sort_by { |annotation| annotation[:min_date] } + + boxes = sorted.select { |annotation| annotation[:max_date] }.map.with_index do |annotation, index| + { + type: "box", + xScaleID: "x-axis-0", + xMin: annotation[:min_date], + xMax: annotation[:max_date], + backgroundColor: blazer_map_annotation_box_colors(index), + } + end + + # chartjs annotations don't support labels for box annotations + labels = sorted.select { |annotation| annotation[:label] }.map.with_index do |annotation, index| + { + type: "line", + value: annotation[:min_date], + mode: "vertical", + scaleID: "x-axis-0", + label: { + content: annotation[:label], + enabled: true, + position: "top", + yAdjust: (index * 30) % 210, + }, + } + end + + boxes + labels + end + + private + + def blazer_map_annotation_box_colors(index) + colors = ['#aec7e8', '#ffbb78', '#98df8a', '#ff9896', '#c5b0d5', '#c49c94', '#f7b6d2', '#c7c7c7', '#dbdb8d', '#9edae5'] + colors[index % colors.size] + 'da' + end end end + diff --git a/app/views/blazer/queries/run.html.erb b/app/views/blazer/queries/run.html.erb index 2f433e918..0b517c803 100644 --- a/app/views/blazer/queries/run.html.erb +++ b/app/views/blazer/queries/run.html.erb @@ -62,6 +62,11 @@ <% chart_options.merge!(library: {tooltips: {intersect: false}}) %> <% end %> <% end %> + <% if ["line", "line2"].include?(chart_type) %> + <% chart_options[:library].merge!({ + annotation: { drawTime: "beforeDatasetsDraw", annotations: blazer_format_annotations(@annotations), } + }) %> + <% end %> <% series_library = {} %> <% target_index = @columns.index { |k| k.downcase == "target" } %> <% if target_index %> diff --git a/lib/blazer.rb b/lib/blazer.rb index d98ac94fa..49b1f82a7 100644 --- a/lib/blazer.rb +++ b/lib/blazer.rb @@ -15,6 +15,7 @@ require_relative "blazer/result_cache" require_relative "blazer/run_statement" require_relative "blazer/statement" +require_relative "blazer/annotations" # adapters require_relative "blazer/adapters/base_adapter" @@ -67,6 +68,9 @@ class << self attr_accessor :forecasting attr_accessor :async attr_accessor :images + attr_accessor :annotations + attr_accessor :query_viewable + attr_accessor :query_editable attr_accessor :override_csp attr_accessor :slack_oauth_token attr_accessor :slack_webhook_url diff --git a/lib/blazer/annotations.rb b/lib/blazer/annotations.rb new file mode 100644 index 000000000..c7016b8ea --- /dev/null +++ b/lib/blazer/annotations.rb @@ -0,0 +1,46 @@ +module Blazer + class Annotations + attr_reader :annotations + + def initialize(annotations) + @annotations = annotations.map { |name, annotation| { query: annotation, name: name } } + end + + def call(result) + return [] unless result.chart_type.in?(["line", "line2"]) + min, max = result.rows.map(&:first).minmax + annotations.map { |annotation| fetch_annotation(annotation, result, min, max) }.flatten + end + + private + + def fetch_annotation(annotation, result, min_date, max_date) + query = build_query(annotation, max_date, min_date) + results = result.data_source.run_statement(query).rows + if results.first.size == 3 # boxes + results.map do |row| + { + min_date: row[0], + max_date: row[1], + label: row[2], + } + end + elsif results.first.size == 2 # lines + results.map do |row| + { + min_date: row[0], + label: row[1], + } + end + else + [] + end + end + + def build_query(annotation, max_date, min_date) + query = annotation[:query] + query = ActiveRecord::Base.send(:sanitize_sql_array, [query.sub("{min_date}", "(?)"), min_date]) + ActiveRecord::Base.send(:sanitize_sql_array, [query.sub("{max_date}", "(?)"), max_date]) + end + end +end diff --git a/lib/blazer/data_source.rb b/lib/blazer/data_source.rb index ebac3abd8..01ee9f8be 100644 --- a/lib/blazer/data_source.rb +++ b/lib/blazer/data_source.rb @@ -35,6 +35,10 @@ def variable_defaults settings["variable_defaults"] || {} end + def annotations + settings["annotations"] || {} + end + def timeout settings["timeout"] end diff --git a/lib/generators/blazer/templates/config.yml.tt b/lib/generators/blazer/templates/config.yml.tt index 66abf6cc1..605eff228 100644 --- a/lib/generators/blazer/templates/config.yml.tt +++ b/lib/generators/blazer/templates/config.yml.tt @@ -32,6 +32,10 @@ data_sources: smart_columns: # user_id: "SELECT id, name FROM users WHERE id IN {value}" + annotations: + # box: SELECT min_date, max_date, label FROM holidays WHERE (min_date, max_date) OVERLAPS ({min_date}, {max_date}) + # line: SELECT date, label FROM deployments WHERE date BETWEEN {min_date} and {max_date} + # create audits audit: true From 37f19ae9943e901343d785850f0c02dfa17f76d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Hasi=C5=84ski?= Date: Fri, 10 Jun 2022 17:45:46 +0200 Subject: [PATCH 2/8] Refactor annotations fetching --- lib/blazer/annotations.rb | 19 ++++++++++--------- lib/generators/blazer/templates/config.yml.tt | 4 ++-- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/lib/blazer/annotations.rb b/lib/blazer/annotations.rb index c7016b8ea..7003437b6 100644 --- a/lib/blazer/annotations.rb +++ b/lib/blazer/annotations.rb @@ -3,7 +3,7 @@ class Annotations attr_reader :annotations def initialize(annotations) - @annotations = annotations.map { |name, annotation| { query: annotation, name: name } } + @annotations = annotations.values end def call(result) @@ -16,17 +16,19 @@ def call(result) def fetch_annotation(annotation, result, min_date, max_date) query = build_query(annotation, max_date, min_date) - results = result.data_source.run_statement(query).rows - if results.first.size == 3 # boxes - results.map do |row| + results = result.data_source.run_statement(query) + return [] unless results.error.nil? + + if results.columns.size == 3 # boxes + results.rows.map do |row| { min_date: row[0], max_date: row[1], label: row[2], } end - elsif results.first.size == 2 # lines - results.map do |row| + elsif results.columns.size == 2 # lines + results.rows.map do |row| { min_date: row[0], label: row[1], @@ -38,9 +40,8 @@ def fetch_annotation(annotation, result, min_date, max_date) end def build_query(annotation, max_date, min_date) - query = annotation[:query] - query = ActiveRecord::Base.send(:sanitize_sql_array, [query.sub("{min_date}", "(?)"), min_date]) - ActiveRecord::Base.send(:sanitize_sql_array, [query.sub("{max_date}", "(?)"), max_date]) + annotation = annotation.sub("{min_date}", "(:min_date)").sub("{max_date}", "(:max_date)") + ActiveRecord::Base.send(:sanitize_sql_array, [annotation, {min_date: min_date, max_date: max_date}]) end end end diff --git a/lib/generators/blazer/templates/config.yml.tt b/lib/generators/blazer/templates/config.yml.tt index 605eff228..123c8b444 100644 --- a/lib/generators/blazer/templates/config.yml.tt +++ b/lib/generators/blazer/templates/config.yml.tt @@ -33,8 +33,8 @@ data_sources: # user_id: "SELECT id, name FROM users WHERE id IN {value}" annotations: - # box: SELECT min_date, max_date, label FROM holidays WHERE (min_date, max_date) OVERLAPS ({min_date}, {max_date}) - # line: SELECT date, label FROM deployments WHERE date BETWEEN {min_date} and {max_date} + # holiday_boxes: SELECT min_date, max_date, label FROM holidays WHERE (min_date, max_date) OVERLAPS ({min_date}, {max_date}) + # deployment_lines: SELECT date, label FROM deployments WHERE date BETWEEN {min_date} and {max_date} # create audits audit: true From 1c2dd75d1b3178f991a924f6cf1d30d246b0be01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Hasi=C5=84ski?= Date: Fri, 10 Jun 2022 17:58:16 +0200 Subject: [PATCH 3/8] Add annotations presence tests --- test/annotations_test.rb | 15 +++++++++++++++ test/internal/config/blazer.yml | 4 ++++ 2 files changed, 19 insertions(+) create mode 100644 test/annotations_test.rb diff --git a/test/annotations_test.rb b/test/annotations_test.rb new file mode 100644 index 000000000..1c521a681 --- /dev/null +++ b/test/annotations_test.rb @@ -0,0 +1,15 @@ +require_relative "test_helper" + +class AnnotationsTest < ActionDispatch::IntegrationTest + def test_line_chart_annotations + run_query "SELECT NOW(), 1" + assert_match "line_annotation", response.body + assert_match "box_annotation", response.body + end + + def test_other_chart_no_annotations + run_query "SELECT 'Label' AS label, 1" + refute_match "line_annotation", response.body + refute_match "box_annotation", response.body + end +end diff --git a/test/internal/config/blazer.yml b/test/internal/config/blazer.yml index 11ab6b398..c66c663fa 100644 --- a/test/internal/config/blazer.yml +++ b/test/internal/config/blazer.yml @@ -36,6 +36,10 @@ data_sources: variable_defaults: default_var: default_value + annotations: + line: SELECT NOW() - INTERVAL '1 hour' as date, 'line_annotation' as label + box: SELECT NOW() - INTERVAL '2 hour' as min_date, NOW() - INTERVAL '1 hour' as max_date, 'box_annotation' as label + # create audits audit: true From 4751672fc9027f965767b7c3ebaea1457a4e9f0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Hasi=C5=84ski?= Date: Fri, 10 Jun 2022 18:11:44 +0200 Subject: [PATCH 4/8] Add README for annotations --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index d72b4ba70..b023f5750 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,32 @@ smart_columns: status: {0: "Active", 1: "Archived"} ``` +### Annotations + +Shows overlay lines or box ranges for line queries. + +Suppose your sales data and your deployments data, given a query: + +```sql +SELECT date_trunc('hour', created_at), sum(value) FROM sales GROUP BY 1 +``` + +You might want to see the influence of a deployment for those sales. + +```yml +annotations: + deployments: SELECT date, name FROM deployments WHERE date BETWEEN {min_date} AND {max_date} +``` + +You can also show periods: + +```yml +annotations: + holidays: SELECT min_date, max_date, name FROM holidays WHERE (min_date, max_date) OVERLAPS ({min_date}, {max_date}) +``` + +Conditions for those queries are optional, but they will help to only fetch the relevant annotations for a particular chart. + ### Caching Blazer can automatically cache results to improve speed. It can cache slow queries: From 04137a4ecd9c2171a9fe9f37093f97639cfefc23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Hasi=C5=84ski?= Date: Fri, 10 Jun 2022 19:35:56 +0200 Subject: [PATCH 5/8] Adjust charts annotation ui --- app/helpers/blazer/base_helper.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/helpers/blazer/base_helper.rb b/app/helpers/blazer/base_helper.rb index 97e43d4a8..65bf6d44a 100644 --- a/app/helpers/blazer/base_helper.rb +++ b/app/helpers/blazer/base_helper.rb @@ -57,11 +57,13 @@ def blazer_format_annotations(annotations) value: annotation[:min_date], mode: "vertical", scaleID: "x-axis-0", + borderColor: '#00000050', + drawTime: "afterDatasetsDraw", label: { content: annotation[:label], enabled: true, - position: "top", - yAdjust: (index * 30) % 210, + position: "bottom", + yAdjust: 30 + (index * 30) % 60, }, } end From d0b7a2461d30091751c0b3978980359ca311fb44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Hasi=C5=84ski?= Date: Mon, 13 Jun 2022 19:37:42 +0200 Subject: [PATCH 6/8] Add an ability to override colors --- app/helpers/blazer/base_helper.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/helpers/blazer/base_helper.rb b/app/helpers/blazer/base_helper.rb index 65bf6d44a..67171da1e 100644 --- a/app/helpers/blazer/base_helper.rb +++ b/app/helpers/blazer/base_helper.rb @@ -46,7 +46,7 @@ def blazer_format_annotations(annotations) xScaleID: "x-axis-0", xMin: annotation[:min_date], xMax: annotation[:max_date], - backgroundColor: blazer_map_annotation_box_colors(index), + backgroundColor: annotation[:color] || blazer_map_annotation_box_colors(index), } end @@ -57,7 +57,7 @@ def blazer_format_annotations(annotations) value: annotation[:min_date], mode: "vertical", scaleID: "x-axis-0", - borderColor: '#00000050', + borderColor: annotation[:color] || '#00000050', drawTime: "afterDatasetsDraw", label: { content: annotation[:label], From 7350520a8202b9d8511e4c7cb34169a9f7c625f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Hasi=C5=84ski?= Date: Mon, 13 Jun 2022 21:26:00 +0200 Subject: [PATCH 7/8] Allow overriding annotation service --- app/controllers/blazer/queries_controller.rb | 2 +- lib/blazer.rb | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/controllers/blazer/queries_controller.rb b/app/controllers/blazer/queries_controller.rb index 45a205eb6..3efde104a 100644 --- a/app/controllers/blazer/queries_controller.rb +++ b/app/controllers/blazer/queries_controller.rb @@ -258,7 +258,7 @@ def render_run @linked_columns = @data_source.linked_columns @markers = [] - @annotations = Blazer::Annotations.new(@data_source.annotations).call(@result) + @annotations = Blazer.annotations.new(@data_source.annotations).call(@result) @geojson = [] set_map_data if Blazer.maps? diff --git a/lib/blazer.rb b/lib/blazer.rb index 49b1f82a7..084ad0987 100644 --- a/lib/blazer.rb +++ b/lib/blazer.rb @@ -84,6 +84,7 @@ class << self self.async = false self.images = false self.override_csp = false + self.annotations = Blazer::Annotations VARIABLE_MESSAGE = "Variable cannot be used in this position" TIMEOUT_MESSAGE = "Query timed out :(" From 640464e09b02ca7e89efde72a0c0bbd121b8ee8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Hasi=C5=84ski?= Date: Sun, 24 Sep 2023 10:22:54 +0200 Subject: [PATCH 8/8] Clean up rebase --- lib/blazer.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/blazer.rb b/lib/blazer.rb index 084ad0987..ceb89c8b9 100644 --- a/lib/blazer.rb +++ b/lib/blazer.rb @@ -69,8 +69,6 @@ class << self attr_accessor :async attr_accessor :images attr_accessor :annotations - attr_accessor :query_viewable - attr_accessor :query_editable attr_accessor :override_csp attr_accessor :slack_oauth_token attr_accessor :slack_webhook_url