UNPKG

highcharts

Version:
764 lines (763 loc) 26.6 kB
/* * * * (c) 2009-2024 Highsoft AS * * Authors: Øystein Moseng, Torstein Hønsi, Jon A. Nygård * * License: www.highcharts.com/license * * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!! * * */ 'use strict'; import A from '../../Core/Animation/AnimationUtilities.js'; const { animObject } = A; import DDU from './DragDropUtilities.js'; const { addEvents, countProps, getFirstProp, getNormalizedEvent } = DDU; import DragDropDefaults from './DragDropDefaults.js'; import H from '../../Core/Globals.js'; const { doc } = H; import U from '../../Core/Utilities.js'; const { addEvent, isArray, merge, pick } = U; /* * * * Functions * * */ /** * Add events to document and chart if the chart is draggable. * * @private * @function addDragDropEvents * @param {Highcharts.Chart} chart * The chart to add events to. */ function addDragDropEvents(chart) { const container = chart.container; // Only enable if we have a draggable chart if (isChartDraggable(chart)) { addEvents(container, ['mousedown', 'touchstart'], (e) => { mouseDown(getNormalizedEvent(e, chart), chart); }); addEvents(container, ['mousemove', 'touchmove'], (e) => { mouseMove(getNormalizedEvent(e, chart), chart); }, { passive: false }); addEvent(container, 'mouseleave', (e) => { mouseUp(getNormalizedEvent(e, chart), chart); }); chart.unbindDragDropMouseUp = addEvents(doc, ['mouseup', 'touchend'], (e) => { mouseUp(getNormalizedEvent(e, chart), chart); }, { passive: false }); // Add flag to avoid doing this again chart.hasAddedDragDropEvents = true; // Add cleanup to make sure we don't pollute document addEvent(chart, 'destroy', () => { if (chart.unbindDragDropMouseUp) { chart.unbindDragDropMouseUp(); } }); } } /** * Remove the chart's drag handles if they exist. * * @private * @function Highcharts.Chart#hideDragHandles */ function chartHideDragHandles() { const chart = this, dragHandles = (chart.dragHandles || {}); if (dragHandles) { for (const key of Object.keys(dragHandles)) { if (dragHandles[key].destroy) { dragHandles[key].destroy(); } } delete chart.dragHandles; } } /** * Set the state of the guide box. * * @private * @function Highcharts.Chart#setGuideBoxState * @param {string} state * The state to set the guide box to. * @param {Highcharts.Dictionary<Highcharts.DragDropGuideBoxOptionsObject>} [options] * Additional overall guideBox options to consider. * @return {Highcharts.SVGElement} * The modified guide box. */ function chartSetGuideBoxState(state, options) { const guideBox = this.dragGuideBox, guideBoxOptions = merge(DragDropDefaults.guideBox, options), stateOptions = merge(guideBoxOptions['default'], // eslint-disable-line dot-notation guideBoxOptions[state]); return guideBox .attr({ 'class': stateOptions.className, stroke: stateOptions.lineColor, strokeWidth: stateOptions.lineWidth, fill: stateOptions.color, cursor: stateOptions.cursor, zIndex: stateOptions.zIndex }) // Use pointerEvents 'none' to avoid capturing the click event .css({ pointerEvents: 'none' }); } /** * Check whether the zoomKey or panKey is pressed. * * @private * @function zoomOrPanKeyPressed * @param {global.Event} e * A mouse event. * @return {boolean} * True if the zoom or pan keys are pressed. False otherwise. */ function chartZoomOrPanKeyPressed(e) { // Check whether the panKey and zoomKey are set in chart.userOptions const chart = this, chartOptions = chart.options.chart || {}, panKey = chartOptions.panKey && chartOptions.panKey + 'Key', zoomKey = chart.zooming.key && chart.zooming.key + 'Key'; return (e[zoomKey] || e[panKey]); } /** * Composes the chart class with essential functions to support draggable * points. * * @private * @function compose * * @param {Highcharts.Chart} ChartClass * Class constructor of chart. */ function compose(ChartClass) { const chartProto = ChartClass.prototype; if (!chartProto.hideDragHandles) { chartProto.hideDragHandles = chartHideDragHandles; chartProto.setGuideBoxState = chartSetGuideBoxState; chartProto.zoomOrPanKeyPressed = chartZoomOrPanKeyPressed; addEvent(ChartClass, 'render', onChartRender); } } /** * Default mouse move handler while dragging. Handles updating points or guide * box. * * @private * @function dragMove * @param {Highcharts.PointerEventObject} e * The mouse move event. * @param {Highcharts.Point} point * The point that is dragged. */ function dragMove(e, point) { const series = point.series, chart = series.chart, data = chart.dragDropData, options = merge(series.options.dragDrop, point.options.dragDrop), draggableX = options.draggableX, draggableY = options.draggableY, origin = data.origin, updateProp = data.updateProp; let dX = e.chartX - origin.chartX, dY = e.chartY - origin.chartY; const oldDx = dX; // Handle inverted if (chart.inverted) { dX = -dY; dY = -oldDx; } // If we have liveRedraw enabled, update the points immediately. Otherwise // update the guideBox. if (pick(options.liveRedraw, true)) { updatePoints(chart, false); // Update drag handles point.showDragHandles(); } else { // No live redraw, update guide box if (updateProp) { // We are resizing, so resize the guide box resizeGuideBox(point, dX, dY); } else { // We are moving, so move the guide box chart.dragGuideBox.translate(draggableX ? dX : 0, draggableY ? dY : 0); } } // Update stored previous dX/Y origin.prevdX = dX; origin.prevdY = dY; } /** * Flip a side property, used with resizeRect. If input side is "left", return * "right" etc. * * @private * @function flipResizeSide * * @param {string} side * Side prop to flip. Can be `left`, `right`, `top` or `bottom`. * * @return {"bottom"|"left"|"right"|"top"|undefined} * The flipped side. */ function flipResizeSide(side) { return { left: 'right', right: 'left', top: 'bottom', bottom: 'top' }[side]; } /** * Get a list of points that are grouped with this point. If only one point is * in the group, that point is returned by itself in an array. * * @private * @function getGroupedPoints * @param {Highcharts.Point} point * Point to find group from. * @return {Array<Highcharts.Point>} * Array of points in this group. */ function getGroupedPoints(point) { const series = point.series, data = series.options.data || [], groupKey = series.options.dragDrop.groupBy; let points = []; if (series.boosted && isArray(data)) { // #11156 for (let i = 0, iEnd = data.length; i < iEnd; ++i) { points.push(new series.pointClass(// eslint-disable-line new-cap series, data[i])); points[points.length - 1].index = i; } } else { points = series.points; } return point.options[groupKey] ? // If we have a grouping option, filter the points by that points.filter((comparePoint) => (comparePoint.options[groupKey] === point.options[groupKey])) : // Otherwise return the point by itself only [point]; } /** * Calculate new point options from points being dragged. * * @private * @function getNewPoints * * @param {Object} dragDropData * A chart's dragDropData with drag/drop origin information, and info on * which points are being dragged. * * @param {Highcharts.PointerEventObject} newPos * Event with the new position of the mouse (chartX/Y properties). * * @return {Highchats.Dictionary<object>} * Hashmap with point.id mapped to an object with the original point * reference, as well as the new data values. */ function getNewPoints(dragDropData, newPos) { const point = dragDropData.point, series = point.series, chart = series.chart, options = merge(series.options.dragDrop, point.options.dragDrop), updateProps = {}, resizeProp = dragDropData.updateProp, hashmap = {}, dragDropProps = point.series.dragDropProps; // Go through the data props that can be updated on this series and find out // which ones we want to update. // eslint-disable-next-line guard-for-in for (const key in dragDropProps) { const val = dragDropProps[key]; // If we are resizing, skip if this key is not the correct one or it // is not resizable. if (resizeProp && (resizeProp !== key || !val.resize || val.optionName && options[val.optionName] === false)) { continue; } // If we are resizing, we now know it is good. If we are moving, check // that moving along this axis is enabled, and the prop is movable. // If this prop is enabled, add it to be updated. if (resizeProp || (val.move && (val.axis === 'x' && options.draggableX || val.axis === 'y' && options.draggableY))) { if (chart.mapView) { updateProps[key === 'x' ? 'lon' : 'lat'] = val; } else { updateProps[key] = val; } } } // Go through the points to be updated and get new options for each of them for (const p of // If resizing).forEach(only update the point we are resizing resizeProp ? [point] : dragDropData.groupedPoints) { hashmap[p.id] = { point: p, newValues: p.getDropValues(dragDropData.origin, newPos, updateProps) }; } return hashmap; } /** * Get a snapshot of points, mouse position, and guide box dimensions * * @private * @function getPositionSnapshot * * @param {Highcharts.PointerEventObject} e * Mouse event with mouse position to snapshot. * * @param {Array<Highcharts.Point>} points * Points to take snapshot of. We store the value of the data properties * defined in each series' dragDropProps. * * @param {Highcharts.SVGElement} [guideBox] * The guide box to take snapshot of. * * @return {Object} * Snapshot object. Point properties are placed in a hashmap with IDs as * keys. */ function getPositionSnapshot(e, points, guideBox) { const res = { chartX: e.chartX, chartY: e.chartY, guideBox: guideBox && { x: guideBox.attr('x'), y: guideBox.attr('y'), width: guideBox.attr('width'), height: guideBox.attr('height') }, points: {} }; // Loop over the points and add their props for (const point of points) { const dragDropProps = point.series.dragDropProps || {}, pointProps = {}; // Add all of the props defined in the series' dragDropProps to the // snapshot for (const key of Object.keys(dragDropProps)) { const val = dragDropProps[key], axis = point.series[val.axis + 'Axis']; pointProps[key] = point[key]; // Record how far cursor was from the point when drag started. // This later will be used to calculate new value according to the // current position of the cursor. // e.g. `high` value is translated to `highOffset` if (point.series.chart.mapView && point.plotX && point.plotY) { pointProps[key + 'Offset'] = key === 'x' ? point.plotX : point.plotY; } else { pointProps[key + 'Offset'] = // E.g. yAxis.toPixels(point.high), xAxis.toPixels // (point.end) axis.toPixels(point[key]) - (axis.horiz ? e.chartX : e.chartY); } } pointProps.point = point; // Store reference to point res.points[point.id] = pointProps; } return res; } /** * In mousemove events, check that we have dragged mouse further than the * dragSensitivity before we call mouseMove handler. * * @private * @function hasDraggedPastSensitivity * * @param {Highcharts.PointerEventObject} e * Mouse move event to test. * * @param {Highcharts.Chart} chart * Chart that has started dragging. * * @param {number} sensitivity * Pixel sensitivity to test against. * * @return {boolean} * True if the event is moved past sensitivity relative to the chart's * drag origin. */ function hasDraggedPastSensitivity(e, chart, sensitivity) { const orig = chart.dragDropData.origin, oldX = orig.chartX, oldY = orig.chartY, newX = e.chartX, newY = e.chartY, distance = Math.sqrt((newX - oldX) * (newX - oldX) + (newY - oldY) * (newY - oldY)); return distance > sensitivity; } /** * Prepare chart.dragDropData with origin info, and show the guide box. * * @private * @function initDragDrop * @param {Highcharts.PointerEventObject} e * Mouse event with original mouse position. * @param {Highcharts.Point} point * The point the dragging started on. * @return {void} */ function initDragDrop(e, point) { const groupedPoints = getGroupedPoints(point), series = point.series, chart = series.chart; let guideBox; // If liveRedraw is disabled, show the guide box with the default state if (!pick(series.options.dragDrop && series.options.dragDrop.liveRedraw, true)) { chart.dragGuideBox = guideBox = series.getGuideBox(groupedPoints); chart .setGuideBoxState('default', series.options.dragDrop.guideBox) .add(series.group); } // Store some data on the chart to pick up later chart.dragDropData = { origin: getPositionSnapshot(e, groupedPoints, guideBox), point: point, groupedPoints: groupedPoints, isDragging: true }; } /** * Utility function to test if a chart should have drag/drop enabled, looking at * its options. * * @private * @function isChartDraggable * @param {Highcharts.Chart} chart * The chart to test. * @return {boolean} * True if the chart is drag/droppable. */ function isChartDraggable(chart) { let i = chart.series ? chart.series.length : 0; if ((chart.hasCartesianSeries && !chart.polar) || chart.mapView) { while (i--) { if (chart.series[i].options.dragDrop && isSeriesDraggable(chart.series[i])) { return true; } } } return false; } /** * Utility function to test if a point is movable (any of its props can be * dragged by a move, not just individually). * * @private * @function isPointMovable * @param {Highcharts.Point} point * The point to test. * @return {boolean} * True if the point is movable. */ function isPointMovable(point) { const series = point.series, chart = series.chart, seriesDragDropOptions = series.options.dragDrop || {}, pointDragDropOptions = point.options && point.options.dragDrop, updateProps = series.dragDropProps; let p, hasMovableX, hasMovableY; // eslint-disable-next-line guard-for-in for (const key in updateProps) { p = updateProps[key]; if (p.axis === 'x' && p.move) { hasMovableX = true; } else if (p.axis === 'y' && p.move) { hasMovableY = true; } } // We can only move the point if draggableX/Y is set, even if all the // individual prop options are set. return ((seriesDragDropOptions.draggableX && hasMovableX || seriesDragDropOptions.draggableY && hasMovableY) && !(pointDragDropOptions && pointDragDropOptions.draggableX === false && pointDragDropOptions.draggableY === false) && (!!(series.yAxis && series.xAxis) || chart.mapView)); } /** * Utility function to test if a series is using drag/drop, looking at its * options. * * @private * @function isSeriesDraggable * @param {Highcharts.Series} series * The series to test. * @return {boolean} * True if the series is using drag/drop. */ function isSeriesDraggable(series) { const props = ['draggableX', 'draggableY'], dragDropProps = series.dragDropProps || {}; let val; // Add optionNames from dragDropProps to the array of props to check for for (const key of Object.keys(dragDropProps)) { val = dragDropProps[key]; if (val.optionName) { props.push(val.optionName); } } // Loop over all options we have that could enable dragDrop for this // series. If any of them are truthy, this series is draggable. let i = props.length; while (i--) { if (series.options.dragDrop[props[i]]) { return true; } } } /** * On container mouse down. Init dragdrop if conditions are right. * * @private * @function mouseDown * @param {Highcharts.PointerEventObject} e * The mouse down event. * @param {Highcharts.Chart} chart * The chart we are clicking. */ function mouseDown(e, chart) { const dragPoint = chart.hoverPoint, dragDropOptions = merge(dragPoint && dragPoint.series.options.dragDrop, dragPoint && dragPoint.options.dragDrop), draggableX = dragDropOptions.draggableX || false, draggableY = dragDropOptions.draggableY || false; // Reset cancel click chart.cancelClick = false; // Ignore if: if ( // Option is disabled for the point !(draggableX || draggableY) || // Zoom/pan key is pressed chart.zoomOrPanKeyPressed(e) || // Dragging an annotation chart.hasDraggedAnnotation) { return; } // If we somehow get a mousedown event while we are dragging, cancel if (chart.dragDropData && chart.dragDropData.isDragging) { mouseUp(e, chart); return; } // If this point is movable, start dragging it if (dragPoint && isPointMovable(dragPoint)) { chart.mouseIsDown = false; // Prevent zooming initDragDrop(e, dragPoint); dragPoint.firePointEvent('dragStart', e); } } /** * On container mouse move. Handle drag sensitivity and fire drag event. * * @private * @function mouseMove * @param {Highcharts.PointerEventObject} e * The mouse move event. * @param {Highcharts.Chart} chart * The chart we are moving across. */ function mouseMove(e, chart) { // Ignore if zoom/pan key is pressed if (chart.zoomOrPanKeyPressed(e)) { return; } const dragDropData = chart.dragDropData; let point, seriesDragDropOpts, newPoints, numNewPoints = 0, newPoint; if (dragDropData && dragDropData.isDragging && dragDropData.point.series) { point = dragDropData.point; seriesDragDropOpts = point.series.options.dragDrop; // No tooltip for dragging e.preventDefault(); // Update sensitivity test if not passed yet if (!dragDropData.draggedPastSensitivity) { dragDropData.draggedPastSensitivity = hasDraggedPastSensitivity(e, chart, pick(point.options.dragDrop && point.options.dragDrop.dragSensitivity, seriesDragDropOpts && seriesDragDropOpts.dragSensitivity, DragDropDefaults.dragSensitivity)); } // If we have dragged past dragSensitivity, run the mousemove handler // for dragging if (dragDropData.draggedPastSensitivity) { // Find the new point values from the moving dragDropData.newPoints = getNewPoints(dragDropData, e); // If we are only dragging one point, add it to the event newPoints = dragDropData.newPoints; numNewPoints = countProps(newPoints); newPoint = numNewPoints === 1 ? getFirstProp(newPoints) : null; // Run the handler point.firePointEvent('drag', { origin: dragDropData.origin, newPoints: dragDropData.newPoints, newPoint: newPoint && newPoint.newValues, newPointId: newPoint && newPoint.point.id, numNewPoints: numNewPoints, chartX: e.chartX, chartY: e.chartY }, function () { dragMove(e, point); }); } } } /** * On container mouse up. Fire drop event and reset state. * * @private * @function mouseUp * @param {Highcharts.PointerEventObject} e * The mouse up event. * @param {Highcharts.Chart} chart * The chart we were dragging in. */ function mouseUp(e, chart) { const dragDropData = chart.dragDropData; if (dragDropData && dragDropData.isDragging && dragDropData.draggedPastSensitivity && dragDropData.point.series) { const point = dragDropData.point, newPoints = dragDropData.newPoints, numNewPoints = countProps(newPoints), newPoint = numNewPoints === 1 ? getFirstProp(newPoints) : null; // Hide the drag handles if (chart.dragHandles) { chart.hideDragHandles(); } // Prevent default action e.preventDefault(); chart.cancelClick = true; // Fire the event, with a default handler that updates the points point.firePointEvent('drop', { origin: dragDropData.origin, chartX: e.chartX, chartY: e.chartY, newPoints: newPoints, numNewPoints: numNewPoints, newPoint: newPoint && newPoint.newValues, newPointId: newPoint && newPoint.point.id }, function () { updatePoints(chart); }); } // Reset delete chart.dragDropData; // Clean up the drag guide box if it exists. This is always added on // drag start, even if user is overriding events. if (chart.dragGuideBox) { chart.dragGuideBox.destroy(); delete chart.dragGuideBox; } } /** * Add event listener to Chart.render that checks whether or not we should add * dragdrop. * @private */ function onChartRender() { // If we don't have dragDrop events, see if we should add them if (!this.hasAddedDragDropEvents) { addDragDropEvents(this); } } /** * Resize the guide box according to point options and a difference in mouse * positions. Handles reversed axes. * * @private * @function resizeGuideBox * @param {Highcharts.Point} point * The point that is being resized. * @param {number} dX * Difference in X position. * @param {number} dY * Difference in Y position. */ function resizeGuideBox(point, dX, dY) { const series = point.series, chart = series.chart, dragDropData = chart.dragDropData, resizeProp = series.dragDropProps[dragDropData.updateProp], // `dragDropProp.resizeSide` holds info on which side to resize. newPoint = dragDropData.newPoints[point.id].newValues, resizeSide = typeof resizeProp.resizeSide === 'function' ? resizeProp.resizeSide(newPoint, point) : resizeProp.resizeSide; // Call resize hook if it is defined if (resizeProp.beforeResize) { resizeProp.beforeResize(chart.dragGuideBox, newPoint, point); } // Do the resize resizeRect(chart.dragGuideBox, resizeProp.axis === 'x' && series.xAxis.reversed || resizeProp.axis === 'y' && series.yAxis.reversed ? flipResizeSide(resizeSide) : resizeSide, { x: resizeProp.axis === 'x' ? dX - (dragDropData.origin.prevdX || 0) : 0, y: resizeProp.axis === 'y' ? dY - (dragDropData.origin.prevdY || 0) : 0 }); } /** * Resize a rect element on one side. The element is modified. * * @private * @function resizeRect * @param {Highcharts.SVGElement} rect * Rect element to resize. * @param {string} updateSide * Which side of the rect to update. Can be `left`, `right`, `top` or * `bottom`. * @param {Highcharts.PositionObject} update * Object with x and y properties, detailing how much to resize each * dimension. * @return {void} */ function resizeRect(rect, updateSide, update) { let resizeAttrs; switch (updateSide) { case 'left': resizeAttrs = { x: rect.attr('x') + update.x, width: Math.max(1, rect.attr('width') - update.x) }; break; case 'right': resizeAttrs = { width: Math.max(1, rect.attr('width') + update.x) }; break; case 'top': resizeAttrs = { y: rect.attr('y') + update.y, height: Math.max(1, rect.attr('height') - update.y) }; break; case 'bottom': resizeAttrs = { height: Math.max(1, rect.attr('height') + update.y) }; break; default: } rect.attr(resizeAttrs); } /** * Update the points in a chart from dragDropData.newPoints. * * @private * @function updatePoints * @param {Highcharts.Chart} chart * A chart with dragDropData.newPoints. * @param {boolean} [animation=true] * Animate updating points? */ function updatePoints(chart, animation) { const newPoints = chart.dragDropData.newPoints, animOptions = animObject(animation); chart.isDragDropAnimating = true; let newPoint; // Update the points for (const key of Object.keys(newPoints)) { newPoint = newPoints[key]; newPoint.point.update(newPoint.newValues, false); } chart.redraw(animOptions); // Clear the isAnimating flag after animation duration is complete. // The complete handler for animation seems to have bugs at this time, so // we have to use a timeout instead. setTimeout(() => { delete chart.isDragDropAnimating; if (chart.hoverPoint && !chart.dragHandles) { chart.hoverPoint.showDragHandles(); } }, animOptions.duration); } /* * * * Default Export * * */ const DraggableChart = { compose, flipResizeSide, initDragDrop }; export default DraggableChart;