UNPKG

chartjs-plugin-crosshair

Version:

Chart.js plugin to draw and sync vertical crosshair lines

659 lines (502 loc) 18.7 kB
/*! * chartjs-plugin-crosshair v1.2.0 * https://chartjs-plugin-crosshair.netlify.com * (c) 2021 Chart.js Contributors * Released under the MIT license */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(require('chart.js'), require('chart.js/helpers')) : typeof define === 'function' && define.amd ? define(['chart.js', 'chart.js/helpers'], factory) : (global = global || self, factory(global.Chart, global.Chart.helpers)); }(this, (function (chart_js, helpers) { 'use strict'; function Interpolate(chart, e, options) { var items = []; for (var datasetIndex = 0; datasetIndex < chart.data.datasets.length; datasetIndex++) { // check for interpolate setting if (!chart.data.datasets[datasetIndex].interpolate) { continue; } var meta = chart.getDatasetMeta(datasetIndex); // do not interpolate hidden charts if (meta.hidden) { continue; } var xScale = chart.scales[meta.xAxisID]; var yScale = chart.scales[meta.yAxisID]; var xValue = xScale.getValueForPixel(e.x); if (xValue > xScale.max || xValue < xScale.min) { continue; } var data = chart.data.datasets[datasetIndex].data; var index = data.findIndex(function(o) { return o.x >= xValue; }); if (index === -1) { continue; } // linear interpolate value var prev = data[index - 1]; var next = data[index]; if (prev && next) { var slope = (next.y - prev.y) / (next.x - prev.x); var interpolatedValue = prev.y + (xValue - prev.x) * slope; } if (chart.data.datasets[datasetIndex].steppedLine && prev) { interpolatedValue = prev.y; } if (isNaN(interpolatedValue)) { continue; } var yPosition = yScale.getPixelForValue(interpolatedValue); // do not interpolate values outside of the axis limits if (isNaN(yPosition)) { continue; } // create a 'fake' event point var fakePoint = { hasValue: function() { return true; }, tooltipPosition: function() { return this._model }, _model: {x: e.x, y: yPosition}, skip: false, stop: false, x: xValue, y: interpolatedValue }; items.push({datasetIndex: datasetIndex, element: fakePoint, index: 0}); } // add other, not interpolated, items var xItems = chart_js.Interaction.modes.x(chart, e, options); for (index = 0; index < xItems.length; index++) { var item = xItems[index]; if (!chart.data.datasets[item.datasetIndex].interpolate) { items.push(item); } } return items; } var defaultOptions = { line: { color: '#F66', width: 1, dashPattern: [] }, sync: { enabled: true, group: 1, suppressTooltips: false }, zoom: { enabled: true, zoomboxBackgroundColor: 'rgba(66,133,244,0.2)', zoomboxBorderColor: '#48F', zoomButtonText: 'Reset Zoom', zoomButtonClass: 'reset-zoom', }, snap: { enabled: false, }, callbacks: { beforeZoom: function(start, end) { return true; }, afterZoom: function(start, end) { } } }; var TracePlugin = { id: 'crosshair', afterInit: function(chart) { if (!chart.config.options.scales.x) { return } var xScaleType = chart.config.options.scales.x.type; if (xScaleType !== 'linear' && xScaleType !== 'time' && xScaleType !== 'category' && xScaleType !== 'logarithmic') { return; } if (chart.options.plugins.crosshair === undefined) { chart.options.plugins.crosshair = defaultOptions; } chart.crosshair = { enabled: false, suppressUpdate: false, x: null, originalData: [], originalXRange: {}, dragStarted: false, dragStartX: null, dragEndX: null, suppressTooltips: false, ignoreNextEvents: 0, reset: function() { this.resetZoom(chart, false, false); }.bind(this) }; var syncEnabled = this.getOption(chart, 'sync', 'enabled'); if (syncEnabled) { chart.crosshair.syncEventHandler = function(e) { this.handleSyncEvent(chart, e); }.bind(this); chart.crosshair.resetZoomEventHandler = function(e) { var syncGroup = this.getOption(chart, 'sync', 'group'); if (e.chartId !== chart.id && e.syncGroup === syncGroup) { this.resetZoom(chart, true); } }.bind(this); window.addEventListener('sync-event', chart.crosshair.syncEventHandler); window.addEventListener('reset-zoom-event', chart.crosshair.resetZoomEventHandler); } chart.panZoom = this.panZoom.bind(this, chart); }, destroy: function(chart) { var syncEnabled = this.getOption(chart, 'sync', 'enabled'); if (syncEnabled) { window.removeEventListener('sync-event', chart.crosshair.syncEventHandler); window.removeEventListener('reset-zoom-event', chart.crosshair.resetZoomEventHandler); } }, panZoom: function(chart, increment) { if (chart.crosshair.originalData.length === 0) { return; } var diff = chart.crosshair.end - chart.crosshair.start; var min = chart.crosshair.min; var max = chart.crosshair.max; if (increment < 0) { // left chart.crosshair.start = Math.max(chart.crosshair.start + increment, min); chart.crosshair.end = chart.crosshair.start === min ? min + diff : chart.crosshair.end + increment; } else { // right chart.crosshair.end = Math.min(chart.crosshair.end + increment, chart.crosshair.max); chart.crosshair.start = chart.crosshair.end === max ? max - diff : chart.crosshair.start + increment; } this.doZoom(chart, chart.crosshair.start, chart.crosshair.end); }, getOption: function(chart, category, name) { return helpers.valueOrDefault(chart.options.plugins.crosshair[category] ? chart.options.plugins.crosshair[category][name] : undefined, defaultOptions[category][name]); }, getXScale: function(chart) { return chart.data.datasets.length ? chart.scales[chart.getDatasetMeta(0).xAxisID] : null; }, getYScale: function(chart) { return chart.scales[chart.getDatasetMeta(0).yAxisID]; }, handleSyncEvent: function(chart, e) { var syncGroup = this.getOption(chart, 'sync', 'group'); // stop if the sync event was fired from this chart if (e.chartId === chart.id) { return; } // stop if the sync event was fired from a different group if (e.syncGroup !== syncGroup) { return; } var xScale = this.getXScale(chart); if (!xScale) { return; } // Safari fix var buttons = (e.original.native.buttons === undefined ? e.original.native.which : e.original.native.buttons); if (e.original.type === 'mouseup') { buttons = 0; } var newEvent = { type: e.original.type == "click" ? "mousemove" : e.original.type, // do not transmit click events to prevent unwanted changing of synced charts. We do need to transmit a event to stop zooming on synced charts however. chart: chart, x: xScale.getPixelForValue(e.xValue), y: e.original.y, native: { buttons: buttons }, stop: true }; chart._eventHandler(newEvent); }, afterEvent: function(chart, event) { if (chart.config.options.scales.x.length == 0) { return } let e = event.event; var xScaleType = chart.config.options.scales.x.type; if (xScaleType !== 'linear' && xScaleType !== 'time' && xScaleType !== 'category' && xscaleType !== 'logarithmic') { return; } var xScale = this.getXScale(chart); if (!xScale) { return; } if(chart.crosshair.ignoreNextEvents > 0) { chart.crosshair.ignoreNextEvents -= 1; return; } // fix for Safari var buttons = (e.native.buttons === undefined ? e.native.which : e.native.buttons); if (e.native.type === 'mouseup') { buttons = 0; } var syncEnabled = this.getOption(chart, 'sync', 'enabled'); var syncGroup = this.getOption(chart, 'sync', 'group'); // fire event for all other linked charts if (!e.stop && syncEnabled) { var event = new CustomEvent('sync-event'); event.chartId = chart.id; event.syncGroup = syncGroup; event.original = e; event.xValue = xScale.getValueForPixel(e.x); window.dispatchEvent(event); } // suppress tooltips for linked charts var suppressTooltips = this.getOption(chart, 'sync', 'suppressTooltips'); chart.crosshair.suppressTooltips = e.stop && suppressTooltips; chart.crosshair.enabled = (e.type !== 'mouseout' && (e.x > xScale.getPixelForValue(xScale.min) && e.x < xScale.getPixelForValue(xScale.max))); if (!chart.crosshair.enabled && !chart.crosshair.suppressUpdate) { if (e.x > xScale.getPixelForValue(xScale.max)) { // suppress future updates to prevent endless redrawing of chart chart.crosshair.suppressUpdate = true; chart.update('none'); } chart.crosshair.dragStarted = false; // cancel zoom in progress return false; } chart.crosshair.suppressUpdate = false; // handle drag to zoom var zoomEnabled = this.getOption(chart, 'zoom', 'enabled'); if (buttons === 1 && !chart.crosshair.dragStarted && zoomEnabled) { chart.crosshair.dragStartX = e.x; chart.crosshair.dragStarted = true; } // handle drag to zoom if (chart.crosshair.dragStarted && buttons === 0) { chart.crosshair.dragStarted = false; var start = xScale.getValueForPixel(chart.crosshair.dragStartX); var end = xScale.getValueForPixel(chart.crosshair.x); if (Math.abs(chart.crosshair.dragStartX - chart.crosshair.x) > 1) { this.doZoom(chart, start, end); } chart.update('none'); } chart.crosshair.x = e.x; chart.draw(); }, afterDraw: function(chart) { if (!chart.crosshair.enabled) { return; } if (chart.crosshair.dragStarted) { this.drawZoombox(chart); } else { this.drawTraceLine(chart); this.interpolateValues(chart); this.drawTracePoints(chart); } return true; }, beforeTooltipDraw: function(chart) { // suppress tooltips on dragging return !chart.crosshair.dragStarted && !chart.crosshair.suppressTooltips; }, resetZoom: function(chart) { var stop = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; var update = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : true; if (update) { if (chart.crosshair.originalData.length > 0) { // reset original data for (var datasetIndex = 0; datasetIndex < chart.data.datasets.length; datasetIndex++) { var dataset = chart.data.datasets[datasetIndex]; dataset.data = chart.crosshair.originalData.shift(0); } } // reset original xRange if (chart.crosshair.originalXRange.min) { chart.options.scales.x.min = chart.crosshair.originalXRange.min; chart.crosshair.originalXRange.min = null; } else { delete chart.options.scales.x.min; } if (chart.crosshair.originalXRange.max) { chart.options.scales.x.max = chart.crosshair.originalXRange.max; chart.crosshair.originalXRange.max = null; } else { delete chart.options.scales.x.max; } } if (chart.crosshair.button && chart.crosshair.button.parentNode) { chart.crosshair.button.parentNode.removeChild(chart.crosshair.button); chart.crosshair.button = false; } var syncEnabled = this.getOption(chart, 'sync', 'enabled'); if (!stop && update && syncEnabled) { var syncGroup = this.getOption(chart, 'sync', 'group'); var event = new CustomEvent('reset-zoom-event'); event.chartId = chart.id; event.syncGroup = syncGroup; window.dispatchEvent(event); } if (update) { chart.update('none'); } }, doZoom: function(chart, start, end) { // swap start/end if user dragged from right to left if (start > end) { var tmp = start; start = end; end = tmp; } // notify delegate var beforeZoomCallback = helpers.valueOrDefault(chart.options.plugins.crosshair.callbacks ? chart.options.plugins.crosshair.callbacks.beforeZoom : undefined, defaultOptions.callbacks.beforeZoom); if (!beforeZoomCallback(start, end)) { return false; } chart.crosshair.dragStarted = false; if (chart.options.scales.x.min && chart.crosshair.originalData.length === 0) { chart.crosshair.originalXRange.min = chart.options.scales.x.min; } if (chart.options.scales.x.max && chart.crosshair.originalData.length === 0) { chart.crosshair.originalXRange.max = chart.options.scales.x.max; } if (!chart.crosshair.button) { // add restore zoom button var button = document.createElement('button'); var buttonText = this.getOption(chart, 'zoom', 'zoomButtonText'); var buttonClass = this.getOption(chart, 'zoom', 'zoomButtonClass'); var buttonLabel = document.createTextNode(buttonText); button.appendChild(buttonLabel); button.className = buttonClass; button.addEventListener('click', function() { this.resetZoom(chart); }.bind(this)); chart.canvas.parentNode.appendChild(button); chart.crosshair.button = button; } // set axis scale chart.options.scales.x.min = start; chart.options.scales.x.max = end; // make a copy of the original data for later restoration var storeOriginals = (chart.crosshair.originalData.length === 0) ? true : false; var filterDataset = (chart.config.options.scales.x.type !== 'category'); if(filterDataset) { for (var datasetIndex = 0; datasetIndex < chart.data.datasets.length; datasetIndex++) { var newData = []; var index = 0; var started = false; var stop = false; if (storeOriginals) { chart.crosshair.originalData[datasetIndex] = chart.data.datasets[datasetIndex].data; } var sourceDataset = chart.crosshair.originalData[datasetIndex]; for (var oldDataIndex = 0; oldDataIndex < sourceDataset.length; oldDataIndex++) { var oldData = sourceDataset[oldDataIndex]; // var oldDataX = this.getXScale(chart).getRightValue(oldData) var oldDataX = oldData.x !== undefined ? oldData.x : NaN; // append one value outside of bounds if (oldDataX >= start && !started && index > 0) { newData.push(sourceDataset[index - 1]); started = true; } if (oldDataX >= start && oldDataX <= end) { newData.push(oldData); } if (oldDataX > end && !stop && index < sourceDataset.length) { newData.push(oldData); stop = true; } index += 1; } chart.data.datasets[datasetIndex].data = newData; } } chart.crosshair.start = start; chart.crosshair.end = end; if (storeOriginals) { var xAxes = this.getXScale(chart); chart.crosshair.min = xAxes.min; chart.crosshair.max = xAxes.max; } chart.crosshair.ignoreNextEvents = 2; // ignore next 2 events to prevent starting a new zoom action after updating the chart chart.update('none'); var afterZoomCallback = this.getOption(chart, 'callbacks', 'afterZoom'); afterZoomCallback(start, end); }, drawZoombox: function(chart) { var yScale = this.getYScale(chart); var borderColor = this.getOption(chart, 'zoom', 'zoomboxBorderColor'); var fillColor = this.getOption(chart, 'zoom', 'zoomboxBackgroundColor'); chart.ctx.beginPath(); chart.ctx.rect(chart.crosshair.dragStartX, yScale.getPixelForValue(yScale.max), chart.crosshair.x - chart.crosshair.dragStartX, yScale.getPixelForValue(yScale.min) - yScale.getPixelForValue(yScale.max)); chart.ctx.lineWidth = 1; chart.ctx.strokeStyle = borderColor; chart.ctx.fillStyle = fillColor; chart.ctx.fill(); chart.ctx.fillStyle = ''; chart.ctx.stroke(); chart.ctx.closePath(); }, drawTraceLine: function(chart) { var yScale = this.getYScale(chart); var lineWidth = this.getOption(chart, 'line', 'width'); var color = this.getOption(chart, 'line', 'color'); var dashPattern = this.getOption(chart, 'line', 'dashPattern'); var snapEnabled = this.getOption(chart, 'snap', 'enabled'); var lineX = chart.crosshair.x; if (snapEnabled && chart._active.length) { lineX = chart._active[0].element.x; } chart.ctx.beginPath(); chart.ctx.setLineDash(dashPattern); chart.ctx.moveTo(lineX, yScale.getPixelForValue(yScale.max)); chart.ctx.lineWidth = lineWidth; chart.ctx.strokeStyle = color; chart.ctx.lineTo(lineX, yScale.getPixelForValue(yScale.min)); chart.ctx.stroke(); chart.ctx.setLineDash([]); }, drawTracePoints: function(chart) { for (var chartIndex = 0; chartIndex < chart.data.datasets.length; chartIndex++) { var dataset = chart.data.datasets[chartIndex]; var meta = chart.getDatasetMeta(chartIndex); var yScale = chart.scales[meta.yAxisID]; if (meta.hidden || !dataset.interpolate) { continue; } chart.ctx.beginPath(); chart.ctx.arc(chart.crosshair.x, yScale.getPixelForValue(dataset.interpolatedValue), 3, 0, 2 * Math.PI, false); chart.ctx.fillStyle = 'white'; chart.ctx.lineWidth = 2; chart.ctx.strokeStyle = dataset.borderColor; chart.ctx.fill(); chart.ctx.stroke(); } }, interpolateValues: function(chart) { for (var chartIndex = 0; chartIndex < chart.data.datasets.length; chartIndex++) { var dataset = chart.data.datasets[chartIndex]; var meta = chart.getDatasetMeta(chartIndex); var xScale = chart.scales[meta.xAxisID]; var xValue = xScale.getValueForPixel(chart.crosshair.x); if (meta.hidden || !dataset.interpolate) { continue; } var data = dataset.data; var index = data.findIndex(function(o) { return o.x >= xValue; }); var prev = data[index - 1]; var next = data[index]; if (chart.data.datasets[chartIndex].steppedLine && prev) { dataset.interpolatedValue = prev.y; } else if (prev && next) { var slope = (next.y - prev.y) / (next.x - prev.x); dataset.interpolatedValue = prev.y + (xValue - prev.x) * slope; } else { dataset.interpolatedValue = NaN; } } } }; // install plugins chart_js.Chart.register(TracePlugin); chart_js.Interaction.modes.interpolate = Interpolate; })));