highcharts
Version:
JavaScript charting framework
764 lines (763 loc) • 26.6 kB
JavaScript
/* *
*
* (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. !!!!!!!
*
* */
;
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;