@antv/g2
Version:
the Grammar of Graphics in Javascript
660 lines (571 loc) • 20.1 kB
text/typescript
import { Text, Group, Circle, Path } from '@antv/g';
import { deepMix, isUndefined, find, get } from '@antv/util';
import type { CircleStyleProps, TextStyleProps, PathStyleProps } from '@antv/g';
import { subObject } from '../utils/helper';
import {
selectPlotArea,
getPointsR,
getPointsPath,
getElements,
getThetaPath,
} from './utils';
export type ElementPointMoveOptions = {
selection?: number[];
precision?: number;
[key: string]: any;
};
const DEFAULT_STYLE = {
pointR: 6,
pointStrokeWidth: 1,
pointStroke: '#888',
pointActiveStroke: '#f5f5f5',
pathStroke: '#888',
pathLineDash: [3, 4],
labelFontSize: 12,
labelFill: '#888',
labelStroke: '#fff',
labelLineWidth: 1,
labelY: -6,
labelX: 2,
};
// point shape name.
const MOVE_POINT_NAME = 'movePoint';
// Element mouseenter change style.
const elementMouseenter = (e) => {
const element = e.target;
const { markType } = element;
// Mark line.
if (markType === 'line') {
element.attr('_lineWidth', element.attr('lineWidth') || 1);
element.attr('lineWidth', element.attr('_lineWidth') + 3);
}
// Mark interval.
if (markType === 'interval') {
element.attr('_opacity', element.attr('opacity') || 1);
element.attr('opacity', 0.7 * element.attr('_opacity'));
}
};
// Element mouseleave change style.
const elementMouseleave = (e) => {
const element = e.target;
const { markType } = element;
// Mark line.
if (markType === 'line') {
element.attr('lineWidth', element.attr('_lineWidth'));
}
// Mark interval.
if (markType === 'interval') {
element.attr('opacity', element.attr('_opacity'));
}
};
// Get the latest overall data based on the individual data changes.
const getNewData = (newChangeData, data, encode) => {
return data.map((d) => {
const isUpdate = ['x', 'color'].reduce((v, key) => {
const field = encode[key];
if (!field) return v;
if (d[field] !== newChangeData[field]) return false;
return v;
}, true);
return isUpdate ? { ...d, ...newChangeData } : d;
});
};
// Find mark interval origin element data.
const getIntervalDataRatioTransformFn = (element) => {
const y = get(element, ['__data__', 'y']);
const y1 = get(element, ['__data__', 'y1']);
const v = y1 - y;
const {
__data__: { data, encode, transform },
childNodes,
} = element.parentNode;
const isNormalizeY = find(transform, ({ type }) => type === 'normalizeY');
const yField = get(encode, ['y', 'field']);
const value = data[childNodes.indexOf(element)][yField];
return (newValue, isTheta = false) => {
if (isNormalizeY || isTheta) {
return (newValue / (1 - newValue) / (v / (1 - v))) * value;
}
return newValue;
};
};
// Find origin path data.
const getPathDataRatioTransformFn = (element, index) => {
const v = get(element, ['__data__', 'seriesItems', index, '0', 'value']);
const i = get(element, ['__data__', 'seriesIndex', index]);
const {
__data__: { data, encode, transform },
} = element.parentNode;
const isNormalizeY = find(transform, ({ type }) => type === 'normalizeY');
const yField = get(encode, ['y', 'field']);
const value = data[i][yField];
return (newValue) => {
if (isNormalizeY) {
if (v === 1) {
return newValue;
}
return (newValue / (1 - newValue) / (v / (1 - v))) * value;
}
return newValue;
};
};
// Point shape select change style.
const selectedPointsStyle = (pointsShape, selection, defaultStyle) => {
pointsShape.forEach((shape, index) => {
shape.attr(
'stroke',
selection[1] === index
? defaultStyle['activeStroke']
: defaultStyle['stroke'],
);
});
};
// Create help show message shape.
const createHelpShape = (
group,
circle,
pathStyle,
labelStyle,
): [Path, Text] => {
const pathShape = new Path({
style: pathStyle,
});
const labelShape = new Text({
style: labelStyle,
});
circle.appendChild(labelShape);
group.appendChild(pathShape);
return [pathShape, labelShape];
};
// Get color scale type.
const getColorType = (scaleColor, color) => {
const indexOf = get(scaleColor, ['options', 'range', 'indexOf']);
if (!indexOf) return;
const i = scaleColor.options.range.indexOf(color);
return scaleColor.sortedDomain[i];
};
// Get the same direction new point.
const getSamePointPosition = (center, point, target) => {
const oldR = getPointsR(center, point);
const newR = getPointsR(center, target);
const ratio = newR / oldR;
const newX = center[0] + (point[0] - center[0]) * ratio;
const newY = center[1] + (point[1] - center[1]) * ratio;
return [newX, newY];
};
/**
* ElementPointMove interaction.
*/
export function ElementPointMove(
elementPointMoveOptions: ElementPointMoveOptions = {},
) {
const { selection = [], precision = 2, ...style } = elementPointMoveOptions;
const defaultStyle = { ...DEFAULT_STYLE, ...(style || {}) };
// Shape default style.
const pathDefaultStyle = subObject(defaultStyle, 'path') as PathStyleProps;
const labelDefaultStyle = subObject(defaultStyle, 'label') as TextStyleProps;
const pointDefaultStyle = subObject(
defaultStyle,
'point',
) as CircleStyleProps;
return (context, _, emitter) => {
const {
update,
setState,
container,
view,
options: { marks, coordinate: coordinateOptions },
} = context;
const plotArea = selectPlotArea(container);
let elements = getElements(plotArea);
let newState;
let newSelection = selection;
const { transform = [], type: coordinateType } = coordinateOptions;
const isTranspose = !!find(transform, ({ type }) => type === 'transpose');
const isPolar = coordinateType === 'polar';
const isTheta = coordinateType === 'theta';
const isArea = !!find(elements, ({ markType }) => markType === 'area');
if (isArea) {
elements = elements.filter(({ markType }) => markType === 'area');
}
// Create points
const pointsGroup = new Group({
style: {
// Tooltip point need down.
zIndex: 2,
},
});
plotArea.appendChild(pointsGroup);
const selectedChange = () => {
emitter.emit('element-point:select', {
nativeEvent: true,
data: {
selection: newSelection,
},
});
};
const dataChange = (changeData, data) => {
emitter.emit('element-point:moved', {
nativeEvent: true,
data: {
changeData,
data,
},
});
};
// Element click change style.
const elementClick = (e) => {
const element = e.target;
newSelection = [element.parentNode.childNodes.indexOf(element)];
selectedChange();
createPoints(element);
};
const elementSelect = (d) => {
const {
data: { selection },
nativeEvent,
} = d;
if (nativeEvent) return;
newSelection = selection;
const element = get(elements, [newSelection?.[0]]);
if (element) {
createPoints(element);
}
};
// Create select element points.
const createPoints = (element) => {
const { attributes, markType, __data__: data } = element;
const { stroke: fill } = attributes;
const { points, seriesTitle, color, title, seriesX, y1 } = data;
// Transpose Currently only do mark interval;
if (isTranspose && markType !== 'interval') return;
const { scale, coordinate } = newState?.view || view;
const { color: scaleColor, y: scaleY, x: scaleX } = scale;
const center = coordinate.getCenter();
pointsGroup.removeChildren();
let downPoint;
const updateView = async (x, y, color, markTypes) => {
setState('elementPointMove', (viewOptions) => {
// Update marks.
const newMarks = (newState?.options?.marks || marks).map((mark) => {
if (!markTypes.includes(mark.type)) return mark;
const { data, encode } = mark;
const encodeKeys = Object.keys(encode);
// Get change new one element data.
const newChangeData = encodeKeys.reduce((value, key) => {
const dataKey = encode[key];
if (key === 'x') {
value[dataKey] = x;
}
if (key === 'y') {
value[dataKey] = y;
}
if (key === 'color') {
value[dataKey] = color;
}
return value;
}, {} as any);
// Get change new all data.
const newData = getNewData(newChangeData, data, encode);
dataChange(newChangeData, newData);
return deepMix({}, mark, {
data: newData,
// No need animate
animate: false,
});
});
return { ...viewOptions, marks: newMarks };
});
return await update('elementPointMove');
};
if (['line', 'area'].includes(markType)) {
points.forEach((p, index) => {
const title = scaleX.invert(seriesX[index]);
// Area points have bottom point.
if (!title) return;
const circle = new Circle({
name: MOVE_POINT_NAME,
style: {
cx: p[0],
cy: p[1],
fill,
...pointDefaultStyle,
},
});
const ratioTransform = getPathDataRatioTransformFn(element, index);
circle.addEventListener('mousedown', (e) => {
const oldPoint = coordinate.output([seriesX[index], 0]);
const pathLength = seriesTitle?.length;
container.attr('cursor', 'move');
if (newSelection[1] !== index) {
newSelection[1] = index;
selectedChange();
}
selectedPointsStyle(
pointsGroup.childNodes,
newSelection,
pointDefaultStyle,
);
const [pathShape, labelShape] = createHelpShape(
pointsGroup,
circle,
pathDefaultStyle,
labelDefaultStyle,
);
// Point move change text
const pointMousemove = (e) => {
const newCy = p[1] + e.clientY - downPoint[1];
// Area/Radar chart.
if (isArea) {
// Radar chart.
if (isPolar) {
const newCx = p[0] + e.clientX - downPoint[0];
const [newX, newY] = getSamePointPosition(center, oldPoint, [
newCx,
newCy,
]);
const [, initY] = coordinate.output([1, scaleY.output(0)]);
const [, y] = coordinate.invert([
newX,
initY - (points[index + pathLength][1] - newY),
]);
const nextIndex = (index + 1) % pathLength;
const lastIndex = (index - 1 + pathLength) % pathLength;
const newPath = getPointsPath([
points[lastIndex],
[newX, newY],
seriesTitle[nextIndex] && points[nextIndex],
]);
labelShape.attr(
'text',
ratioTransform(scaleY.invert(y)).toFixed(precision),
);
pathShape.attr('d', newPath);
circle.attr('cx', newX);
circle.attr('cy', newY);
} else {
// Area chart.
const [, initY] = coordinate.output([1, scaleY.output(0)]);
const [, y] = coordinate.invert([
p[0],
initY - (points[index + pathLength][1] - newCy),
]);
const newPath = getPointsPath([
points[index - 1],
[p[0], newCy],
seriesTitle[index + 1] && points[index + 1],
]);
labelShape.attr(
'text',
ratioTransform(scaleY.invert(y)).toFixed(precision),
);
pathShape.attr('d', newPath);
circle.attr('cy', newCy);
}
} else {
// Line chart.
const [, y] = coordinate.invert([p[0], newCy]);
const newPath = getPointsPath([
points[index - 1],
[p[0], newCy],
points[index + 1],
]);
labelShape.attr('text', scaleY.invert(y).toFixed(precision));
pathShape.attr('d', newPath);
circle.attr('cy', newCy);
}
};
downPoint = [e.clientX, e.clientY];
window.addEventListener('mousemove', pointMousemove);
const mouseupFn = async () => {
container.attr('cursor', 'default');
window.removeEventListener('mousemove', pointMousemove);
container.removeEventListener('mouseup', mouseupFn);
if (isUndefined(labelShape.attr('text'))) return;
const y = Number(labelShape.attr('text'));
const colorType = getColorType(scaleColor, color);
newState = await updateView(title, y, colorType, [
'line',
'area',
]);
labelShape.remove();
pathShape.remove();
createPoints(element);
};
container.addEventListener('mouseup', mouseupFn);
});
pointsGroup.appendChild(circle);
});
selectedPointsStyle(
pointsGroup.childNodes,
newSelection,
pointDefaultStyle,
);
} else if (markType === 'interval') {
// Column chart point.
let circlePoint = [(points[0][0] + points[1][0]) / 2, points[0][1]];
// Bar chart point.
if (isTranspose) {
circlePoint = [points[0][0], (points[0][1] + points[1][1]) / 2];
} else if (isTheta) {
// Pie chart point.
circlePoint = points[0];
}
const ratioTransform = getIntervalDataRatioTransformFn(element);
const circle = new Circle({
name: MOVE_POINT_NAME,
style: {
cx: circlePoint[0],
cy: circlePoint[1],
fill,
...pointDefaultStyle,
stroke: pointDefaultStyle['activeStroke'],
},
});
circle.addEventListener('mousedown', (e) => {
container.attr('cursor', 'move');
const colorType = getColorType(scaleColor, color);
const [pathShape, labelShape] = createHelpShape(
pointsGroup,
circle,
pathDefaultStyle,
labelDefaultStyle,
);
// Point move change text
const pointMousemove = (e) => {
if (isTranspose) {
// Bar chart.
const newCx = circlePoint[0] + e.clientX - downPoint[0];
const [initX] = coordinate.output([
scaleY.output(0),
scaleY.output(0),
]);
const [, x] = coordinate.invert([
initX + (newCx - points[2][0]),
circlePoint[1],
]);
const newPath = getPointsPath(
[
[newCx, points[0][1]],
[newCx, points[1][1]],
points[2],
points[3],
],
true,
);
labelShape.attr(
'text',
ratioTransform(scaleY.invert(x)).toFixed(precision),
);
pathShape.attr('d', newPath);
circle.attr('cx', newCx);
} else if (isTheta) {
// Pie chart.
const newCy = circlePoint[1] + e.clientY - downPoint[1];
const newCx = circlePoint[0] + e.clientX - downPoint[0];
const [newXOut, newYOut] = getSamePointPosition(
center,
[newCx, newCy],
circlePoint,
);
const [newXIn, newYIn] = getSamePointPosition(
center,
[newCx, newCy],
points[1],
);
const lastPercent = coordinate.invert([newXOut, newYOut])[1];
const percent = y1 - lastPercent;
if (percent < 0) return;
const newPath = getThetaPath(
center,
[[newXOut, newYOut], [newXIn, newYIn], points[2], points[3]],
percent > 0.5 ? 1 : 0,
);
labelShape.attr(
'text',
ratioTransform(percent, true).toFixed(precision),
);
pathShape.attr('d', newPath);
circle.attr('cx', newXOut);
circle.attr('cy', newYOut);
} else {
// Column chart.
const newCy = circlePoint[1] + e.clientY - downPoint[1];
const [, initY] = coordinate.output([1, scaleY.output(0)]);
const [, y] = coordinate.invert([
circlePoint[0],
initY - (points[2][1] - newCy),
]);
const newPath = getPointsPath(
[
[points[0][0], newCy],
[points[1][0], newCy],
points[2],
points[3],
],
true,
);
labelShape.attr(
'text',
ratioTransform(scaleY.invert(y)).toFixed(precision),
);
pathShape.attr('d', newPath);
circle.attr('cy', newCy);
}
};
downPoint = [e.clientX, e.clientY];
window.addEventListener('mousemove', pointMousemove);
// Change mosueup change data and update 、clear shape.
const mouseupFn = async () => {
container.attr('cursor', 'default');
container.removeEventListener('mouseup', mouseupFn);
window.removeEventListener('mousemove', pointMousemove);
if (isUndefined(labelShape.attr('text'))) return;
const y = Number(labelShape.attr('text'));
newState = await updateView(title, y, colorType, [markType]);
labelShape.remove();
pathShape.remove();
createPoints(element);
};
container.addEventListener('mouseup', mouseupFn);
});
pointsGroup.appendChild(circle);
}
};
// Add EventListener.
elements.forEach((element, index) => {
if (newSelection[0] === index) {
createPoints(element);
}
element.addEventListener('click', elementClick);
element.addEventListener('mouseenter', elementMouseenter);
element.addEventListener('mouseleave', elementMouseleave);
});
const rootClick = (e) => {
const element = e?.target;
if (
!element ||
(element.name !== MOVE_POINT_NAME && !elements.includes(element))
) {
newSelection = [];
selectedChange();
pointsGroup.removeChildren();
}
};
emitter.on('element-point:select', elementSelect);
emitter.on('element-point:unselect', rootClick);
container.addEventListener('mousedown', rootClick);
// Remove EventListener.
return () => {
pointsGroup.remove();
emitter.off('element-point:select', elementSelect);
emitter.off('element-point:unselect', rootClick);
container.removeEventListener('mousedown', rootClick);
elements.forEach((element) => {
element.removeEventListener('click', elementClick);
element.removeEventListener('mouseenter', elementMouseenter);
element.removeEventListener('mouseleave', elementMouseleave);
});
};
};
}