@antv/g6
Version:
A Graph Visualization Framework in JavaScript
484 lines (399 loc) • 15.3 kB
text/typescript
import { pick } from '@antv/util';
import { CommonEvent } from '../../constants';
import type { CircleStyleProps } from '../../elements';
import { Circle } from '../../elements';
import type { RuntimeContext } from '../../runtime/types';
import type { NodeData } from '../../spec';
import type { NodeStyle } from '../../spec/element/node';
import type { ID, IDragEvent, IPointerEvent, Node, Point, PointObject } from '../../types';
import { arrayDiff } from '../../utils/diff';
import { idOf } from '../../utils/id';
import { parsePoint, toPointObject } from '../../utils/point';
import { positionOf } from '../../utils/position';
import { distance } from '../../utils/vector';
import type { BasePluginOptions } from '../base-plugin';
import { BasePlugin } from '../base-plugin';
/**
* <zh/> 鱼眼放大镜插件配置项
*
* <en/> Fisheye Plugin Options
*/
export interface FisheyeOptions extends BasePluginOptions {
/**
* <zh/> 移动鱼眼放大镜的方式
* - `'pointermove'`:始终跟随鼠标移动
* - `'click'`:鼠标点击时移动
* - `'drag'`:拖拽移动
*
* <en/> The way to move the fisheye lens
* - `'pointermove'`: always follow the mouse movement
* - `'click'`: move when the mouse is clicked
* - `'drag'`: move by dragging
* @defaultValue `'pointermove'`
*/
trigger?: 'pointermove' | 'drag' | 'click';
/**
* <zh/> 鱼眼放大镜半径
*
* <en/> The radius of the fisheye lens
* @remarks
* <img src="https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*unAvQqAb_NMAAAAAAAAAAAAADmJ7AQ/original" width="200" />
* @defaultValue 120
*/
r?: number;
/**
* <zh/> 鱼眼放大镜可调整的最大半径,配合 `scaleRBy` 使用
*
* <en/> The maximum radius that the fisheye lens can be adjusted, used with `scaleRBy`
* @defaultValue 画布宽高的最小值的一半
*/
maxR?: number;
/**
* <zh/> 鱼眼放大镜可调整的最小半径,配合 `scaleRBy` 使用
*
* <en/> The minimum radius that the fisheye lens can be adjusted, used with `scaleRBy`
* @defaultValue 0
*/
minR?: number;
/**
* <zh/> 调整鱼眼放大镜范围半径的方式
* - `'wheel'`:滚轮调整
* - `'drag'`:拖拽调整
*
* <en/> The way to adjust the range radius of the fisheye lens
* - `'wheel'`: adjust by wheel
* - `'drag'`: adjust by drag
* @remarks
* <zh/> 如果 `trigger`、`scaleRBy` 和 `scaleDBy` 同时设置为 `'drag'`,优先级顺序为 `trigger` > `scaleRBy` > `scaleDBy`,只会为优先级最高的配置项绑定拖拽事件。同理,如果 `scaleRBy` 和 `scaleDBy` 同时设置为 `'wheel'`,只会为 `scaleRBy` 绑定滚轮事件
*
* <en/> If `trigger`, `scaleRBy`, and `scaleDBy` are set to `'drag'` at the same time, the priority order is `trigger` > `scaleRBy` > `scaleDBy`, and only the configuration item with the highest priority will be bound to the drag event. Similarly, if `scaleRBy` and `scaleDBy` are set to `'wheel'` at the same time, only `scaleRBy` will be bound to the wheel event
*/
scaleRBy?: 'wheel' | 'drag';
/**
* <zh/> 畸变因子
*
* <en/> Distortion factor
* @remarks
* <img src="https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*4ITFR7GOl8UAAAAAAAAAAAAADmJ7AQ/original" width="200" />
* @defaultValue 1.5
*/
d?: number;
/**
* <zh/> 鱼眼放大镜可调整的最大畸变因子,配合 `scaleDBy` 使用
*
* <en/> The maximum distortion factor that the fisheye lens can be adjusted, used with `scaleDBy`
* @defaultValue 5
*/
maxD?: number;
/**
* <zh/> 鱼眼放大镜可调整的最小畸变因子,配合 `scaleDBy` 使用
*
* <en/> The minimum distortion factor that the fisheye lens can be adjusted, used with `scaleDBy`
* @defaultValue 0
*/
minD?: number;
/**
* <zh/> 调整鱼眼放大镜畸变因子的方式
* - `'wheel'`:滚轮调整
* - `'drag'`:拖拽调整
*
* <en/> The way to adjust the distortion factor of the fisheye lens
* - `'wheel'`: adjust by wheel
* - `'drag'`: adjust by drag
*/
scaleDBy?: 'wheel' | 'drag';
/**
* <zh/> 是否在鱼眼放大镜中显示畸变因子数值
*
* <en/> Whether to display the value of the distortion factor in the fisheye lens
* @defaultValue true
*/
showDPercent?: boolean;
/**
* <zh/> 鱼眼放大镜样式
*
* <en/> Fisheye Lens Style
*/
style?: Partial<CircleStyleProps>;
/**
* <zh/> 在鱼眼放大镜中的节点样式
*
* <en/> Node style in the fisheye lens
*/
nodeStyle?: NodeStyle | ((datum: NodeData) => NodeStyle);
/**
* <zh/> 是否阻止默认事件
*
* <en/> Whether to prevent the default event
* @defaultValue true
*/
preventDefault?: boolean;
}
const defaultLensStyle: Exclude<CircleStyleProps, 'r'> = {
fill: '#ccc',
fillOpacity: 0.1,
lineWidth: 2,
stroke: '#000',
strokeOpacity: 0.8,
labelFontSize: 12,
};
const R_DELTA = 0.05;
const D_DELTA = 0.1;
/**
* <zh/> 鱼眼放大镜
*
* <en/> Fisheye Distortion
* @remarks
* <zh/> Fisheye 鱼眼放大镜是为 focus+context 的探索场景设计的,它能够保证在放大关注区域的同时,保证上下文以及上下文与关注中心的关系不丢失。
*
* <en/> Fisheye is designed for focus+context exploration, it keeps the context and the relationships between context and the focus while magnifying the focus area.
*/
export class Fisheye extends BasePlugin<FisheyeOptions> {
static defaultOptions: Partial<FisheyeOptions> = {
trigger: 'pointermove',
r: 120,
d: 1.5,
maxD: 5,
minD: 0,
showDPercent: true,
style: {},
nodeStyle: { label: true },
preventDefault: true,
};
constructor(context: RuntimeContext, options: FisheyeOptions) {
super(context, Object.assign({}, Fisheye.defaultOptions, options));
this.bindEvents();
}
private lens!: Circle;
private r = this.options.r;
private d = this.options.d;
private get canvas() {
return this.context.canvas.getLayer('transient');
}
private get isLensOn() {
return this.lens && !this.lens.destroyed;
}
protected onCreateFisheye = (event: IPointerEvent) => {
if (this.options.trigger === 'drag' && this.isLensOn) return;
const origin = parsePoint(event.canvas as PointObject);
this.onMagnify(origin);
};
protected onMagnify = (origin: Point) => {
if (origin.some(isNaN)) return;
this.renderLens(origin);
this.renderFocusElements();
};
private renderLens = (origin: Point) => {
const style = Object.assign({}, defaultLensStyle, this.options.style);
if (!this.isLensOn) {
this.lens = new Circle({ style });
this.canvas.appendChild(this.lens);
}
Object.assign(style, toPointObject(origin), {
size: this.r * 2,
label: this.options.showDPercent,
labelText: this.getDPercent(),
});
this.lens.update(style);
};
private getDPercent = () => {
const { minD, maxD } = this.options as Required<FisheyeOptions>;
const percent = Math.round(((this.d - minD) / (maxD - minD)) * 100);
return `${percent}%`;
};
private prevMagnifiedStyleMap = new Map<ID, NodeStyle>();
private prevOriginStyleMap = new Map<ID, NodeStyle>();
private renderFocusElements = () => {
if (!this.isLensOn) return;
const { graph } = this.context;
const origin = this.lens.getCenter();
const molecularParam = (this.d + 1) * this.r;
const magnifiedStyleMap = new Map<ID, NodeStyle>();
const originStyleMap = new Map<ID, NodeStyle>();
const nodeData = graph.getNodeData();
nodeData.forEach((datum) => {
const position = positionOf(datum);
const distanceToOrigin = distance(position, origin);
if (distanceToOrigin > this.r) return;
const magnifiedDistance = (molecularParam * distanceToOrigin) / (this.d * distanceToOrigin + this.r);
const [nodeX, nodeY] = position;
const [originX, originY] = origin;
const cos = (nodeX - originX) / distanceToOrigin;
const sin = (nodeY - originY) / distanceToOrigin;
const newPoint: Point = [originX + magnifiedDistance * cos, originY + magnifiedDistance * sin];
const nodeId = idOf(datum);
const style = this.getNodeStyle(datum);
const originStyle = pick(graph.getElementRenderStyle(nodeId), Object.keys(style));
magnifiedStyleMap.set(nodeId, { ...toPointObject(newPoint), ...style });
originStyleMap.set(nodeId, { ...toPointObject(position), ...originStyle });
});
this.updateStyle(magnifiedStyleMap, originStyleMap);
};
private getNodeStyle = (datum: NodeData) => {
const { nodeStyle } = this.options;
return typeof nodeStyle === 'function' ? nodeStyle(datum) : nodeStyle;
};
private updateStyle = (magnifiedStyleMap: Map<ID, NodeStyle>, originStyleMap: Map<ID, NodeStyle>) => {
const { graph, element } = this.context;
const { enter, exit, keep } = arrayDiff<ID>(
Array.from(this.prevMagnifiedStyleMap.keys()),
Array.from(magnifiedStyleMap.keys()),
(d) => d,
);
const relatedEdges = new Set<ID>();
const update = (nodeId: ID, style: NodeStyle) => {
const node = element!.getElement(nodeId) as Node;
node?.update(style);
graph.getRelatedEdgesData(nodeId).forEach((datum) => {
relatedEdges.add(idOf(datum));
});
};
[...enter, ...keep].forEach((nodeId) => {
update(nodeId, magnifiedStyleMap.get(nodeId)!);
});
exit.forEach((nodeId) => {
update(nodeId, this.prevOriginStyleMap.get(nodeId)!);
this.prevOriginStyleMap.delete(nodeId);
});
relatedEdges.forEach((edgeId) => {
const edge = element!.getElement(edgeId);
edge?.update({});
});
this.prevMagnifiedStyleMap = magnifiedStyleMap;
originStyleMap.forEach((style, nodeId) => {
if (!this.prevOriginStyleMap.has(nodeId)) {
this.prevOriginStyleMap.set(nodeId, style);
}
});
};
private isWheelValid = (event: WheelEvent) => {
if (this.options.preventDefault) event.preventDefault();
if (!this.isLensOn) return false;
const { clientX, clientY } = event;
const scaleOrigin = this.context.graph.getCanvasByClient([clientX, clientY]);
const origin = this.lens.getCenter();
if (distance(scaleOrigin, origin) > this.r) return false;
return true;
};
private scaleR = (positive: boolean) => {
const { maxR, minR } = this.options;
const ratio = positive ? 1 / (1 - R_DELTA) : 1 - R_DELTA;
const canvasR = Math.min(...this.context.canvas.getSize()) / 2;
this.r = Math.max(minR || 0, Math.min(maxR || canvasR, this.r * ratio));
};
private scaleD = (positive: boolean) => {
const { maxD, minD } = this.options as Required<FisheyeOptions>;
const newD = positive ? this.d + D_DELTA : this.d - D_DELTA;
this.d = Math.max(minD, Math.min(maxD, newD));
};
private scaleRByWheel = (event: WheelEvent) => {
if (!this.isWheelValid(event)) return;
const { deltaX, deltaY } = event;
this.scaleR(deltaX + deltaY > 0);
const origin = this.lens.getCenter();
this.onMagnify(origin);
};
private scaleDByWheel = (event: WheelEvent) => {
if (!this.isWheelValid(event)) return;
const { deltaX, deltaY } = event;
this.scaleD(deltaX + deltaY > 0);
const origin = this.lens.getCenter();
this.onMagnify(origin);
};
private isDragValid = (event: IDragEvent) => {
if (this.options.preventDefault) event.preventDefault();
if (!this.isLensOn) return false;
const dragOrigin = parsePoint(event.canvas as PointObject);
const origin = this.lens.getCenter();
if (distance(dragOrigin, origin) > this.r) return false;
return true;
};
private isLensDragging = false;
private onDragStart = (event: IDragEvent) => {
if (!this.isDragValid(event)) return;
this.isLensDragging = true;
};
private onDrag = (event: IDragEvent) => {
if (!this.isLensDragging) return;
const dragOrigin = parsePoint(event.canvas as PointObject);
this.onMagnify(dragOrigin);
};
private onDragEnd = () => {
this.isLensDragging = false;
};
private scaleRByDrag = (event: IDragEvent) => {
if (!this.isLensDragging) return;
const { dx, dy } = event;
this.scaleR(dx - dy > 0);
const origin = this.lens.getCenter();
this.onMagnify(origin);
};
private scaleDByDrag = (event: IDragEvent) => {
if (!this.isLensDragging) return;
const { dx, dy } = event;
this.scaleD(dx - dy > 0);
const origin = this.lens.getCenter();
this.onMagnify(origin);
};
get graphDom() {
return this.context.graph.getCanvas().getContextService().getDomElement();
}
private bindEvents() {
const { graph } = this.context;
const { trigger, scaleRBy, scaleDBy } = this.options;
const canvas = graph.getCanvas().getLayer();
if (['click', 'drag'].includes(trigger)) {
canvas.addEventListener(CommonEvent.CLICK, this.onCreateFisheye);
}
if (trigger === 'pointermove') {
canvas.addEventListener(CommonEvent.POINTER_MOVE, this.onCreateFisheye);
}
if (trigger === 'drag' || scaleRBy === 'drag' || scaleDBy === 'drag') {
canvas.addEventListener(CommonEvent.DRAG_START, this.onDragStart);
canvas.addEventListener(CommonEvent.DRAG_END, this.onDragEnd);
const dragFunc = trigger === 'drag' ? this.onDrag : scaleRBy === 'drag' ? this.scaleRByDrag : this.scaleDByDrag;
canvas.addEventListener(CommonEvent.DRAG, dragFunc);
}
if (scaleRBy === 'wheel' || scaleDBy === 'wheel') {
const wheelFunc = scaleRBy === 'wheel' ? this.scaleRByWheel : this.scaleDByWheel;
this.graphDom?.addEventListener(CommonEvent.WHEEL, wheelFunc, { passive: false });
}
}
private unbindEvents() {
const { graph } = this.context;
const { trigger, scaleRBy, scaleDBy } = this.options;
const canvas = graph.getCanvas().getLayer();
if (['click', 'drag'].includes(trigger)) {
canvas.removeEventListener(CommonEvent.CLICK, this.onCreateFisheye);
}
if (trigger === 'pointermove') {
canvas.removeEventListener(CommonEvent.POINTER_MOVE, this.onCreateFisheye);
}
if (trigger === 'drag' || scaleRBy === 'drag' || scaleDBy === 'drag') {
canvas.removeEventListener(CommonEvent.DRAG_START, this.onDragStart);
canvas.removeEventListener(CommonEvent.DRAG_END, this.onDragEnd);
const dragFunc = trigger === 'drag' ? this.onDrag : scaleRBy === 'drag' ? this.scaleRByDrag : this.scaleDByDrag;
canvas.removeEventListener(CommonEvent.DRAG, dragFunc);
}
if (scaleRBy === 'wheel' || scaleDBy === 'wheel') {
const wheelFunc = scaleRBy === 'wheel' ? this.scaleRByWheel : this.scaleDByWheel;
this.graphDom?.removeEventListener(CommonEvent.WHEEL, wheelFunc);
}
}
public update(options: Partial<FisheyeOptions>) {
this.unbindEvents();
super.update(options);
this.r = options.r ?? this.r;
this.d = options.d ?? this.d;
this.bindEvents();
}
public destroy() {
this.unbindEvents();
if (this.isLensOn) {
this.lens?.destroy();
}
this.prevMagnifiedStyleMap.clear();
this.prevOriginStyleMap.clear();
super.destroy();
}
}