import { CommonEvent } from '../../constants';
import { Circle, type CircleStyleProps } from '../../elements';
import type { RuntimeContext } from '../../runtime/types';
import type { EdgeData, GraphData, NodeData } from '../../spec';
import type { EdgeStyle } from '../../spec/element/edge';
import type { NodeStyle } from '../../spec/element/node';
import type {
Element,
ElementDatum,
ElementType,
ID,
IDragEvent,
IPointerEvent,
Point,
PointObject,
} from '../../types';
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';
/**
* 边过滤镜插件配置项
*
* Edge filter lens plugin options
*/
export interface EdgeFilterLensOptions extends BasePluginOptions {
/**
* 移动透镜的方式
* - `'pointermove'`:始终跟随鼠标移动
* - `'click'`:鼠标点击时透镜移动
* - `'drag'`:拖拽透镜
*
* The way to move the lens
* - `'pointermove'`: always follow the mouse movement
* - `'click'`: move the lens when the mouse clicks
* - `'drag'`: drag the lens
* @defaultValue 'pointermove'
*/
trigger?: 'pointermove' | 'click' | 'drag';
/**
* 透镜的半径
*
* The radius of the lens
* @defaultValue 60
*/
r?: number;
/**
* 透镜的最大半径。只有在 `scaleRBy` 为 `wheel` 时生效
*
* The maximum radius of the lens. Only valid when `scaleRBy` is `wheel`
* @defaultValue canvas 宽高最小值的一半
*/
maxR?: number;
/**
* 透镜的最小半径。只有在 `scaleRBy` 为 `wheel` 时生效
*
* The minimum radius of the lens. Only valid when `scaleRBy` is `wheel`
* @defaultValue 0
*/
minR?: number;
/**
* 缩放透镜半径的方式
* - `'wheel'`:通过滚轮缩放透镜的半径
*
* The way to scale the radius of the lens
* - `'wheel'`: scale the radius of the lens by the wheel
* @defaultValue `'wheel'`
*/
scaleRBy?: 'wheel';
/**
* 边显示的条件
* - `'both'`:只有起始节点和目标节点都在透镜中时,边才会显示
* - `'source'`:只有起始节点在透镜中时,边才会显示
* - `'target'`:只有目标节点在透镜中时,边才会显示
* - `'either'`:只要起始节点或目标节点有一个在透镜中时,边就会显示
*
* The condition for displaying the edge
* - `'both'`: The edge is displayed only when both the source node and the target node are in the lens
* - `'source'`: The edge is displayed only when the source node is in the lens
* - `'target'`: The edge is displayed only when the target node is in the lens
* - `'either'`: The edge is displayed when either the source node or the target node is in the lens
* @defaultValue 'both'
*/
nodeType?: 'both' | 'source' | 'target' | 'either';
/**
* 过滤出始终不在透镜中显示的元素
*
* Filter elements that are never displayed in the lens
* @param id - 元素的 id | The id of the element
* @param elementType - 元素的类型 | The type of the element
* @returns 是否显示 | Whether to display
*/
filter?: (id: ID, elementType: ElementType) => boolean;
/**
* 透镜的样式
*
* The style of the lens
*/
style?: Partial;
/**
* 在透镜中节点的样式
*
* The style of the nodes displayed in the lens
*/
nodeStyle?: NodeStyle | ((datum: NodeData) => NodeStyle);
/**
* 在透镜中边的样式
*
* The style of the edges displayed in the lens
*/
edgeStyle?: EdgeStyle | ((datum: EdgeData) => EdgeStyle);
/**
* 是否阻止默认事件
*
* Whether to prevent the default event
* @defaultValue true
*/
preventDefault?: boolean;
}
const defaultLensStyle: Exclude = {
fill: '#fff',
fillOpacity: 1,
lineWidth: 1,
stroke: '#000',
strokeOpacity: 0.8,
zIndex: -Infinity,
};
const DELTA = 0.05;
/**
* 边过滤镜插件
*
* Edge filter lens plugin
* @remarks
* 边过滤镜可以将关注的边保留在过滤镜范围内,其他边将在该范围内不显示。
*
* EdgeFilterLens can keep the focused edges within the lens range, while other edges will not be displayed within that range.
*/
export class EdgeFilterLens extends BasePlugin {
static defaultOptions: Partial = {
trigger: 'pointermove',
r: 60,
nodeType: 'both',
filter: () => true,
style: { lineWidth: 2 },
nodeStyle: { label: false },
edgeStyle: { label: true },
scaleRBy: 'wheel',
preventDefault: true,
};
constructor(context: RuntimeContext, options: EdgeFilterLensOptions) {
super(context, Object.assign({}, EdgeFilterLens.defaultOptions, options));
this.bindEvents();
}
private lens!: Circle;
private shapes = new Map();
private r = this.options.r;
private get canvas() {
return this.context.canvas.getLayer('transient');
}
private get isLensOn() {
return this.lens && !this.lens.destroyed;
}
protected onEdgeFilter = (event: IPointerEvent) => {
if (this.options.trigger === 'drag' && this.isLensOn) return;
const origin = parsePoint(event.canvas as PointObject);
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 });
this.lens.update(style);
};
private getFilterData = (): Required => {
const { filter } = this.options;
const { model } = this.context;
const data = model.getData();
if (!filter) return data;
const { nodes, edges, combos } = data;
return {
nodes: nodes.filter((node) => filter(idOf(node), 'node')),
edges: edges.filter((edge) => filter(idOf(edge), 'edge')),
combos: combos.filter((combo) => filter(idOf(combo), 'combo')),
};
};
private getFocusElements = (origin: Point) => {
const { nodes, edges } = this.getFilterData();
const focusNodes = nodes.filter((datum) => distance(positionOf(datum), origin) < this.r);
const focusNodeIds = focusNodes.map((node) => idOf(node));
const focusEdges = edges.filter((datum) => {
const { source, target } = datum;
const isSourceFocus = focusNodeIds.includes(source);
const isTargetFocus = focusNodeIds.includes(target);
switch (this.options.nodeType) {
case 'both':
return isSourceFocus && isTargetFocus;
case 'either':
return isSourceFocus !== isTargetFocus;
case 'source':
return isSourceFocus && !isTargetFocus;
case 'target':
return !isSourceFocus && isTargetFocus;
default:
return false;
}
});
return { nodes: focusNodes, edges: focusEdges };
};
private renderFocusElements = () => {
const { element, graph } = this.context;
if (!this.isLensOn) return;
const origin = this.lens.getCenter();
const { nodes, edges } = this.getFocusElements(origin);
const ids = new Set();
const iterate = (datum: ElementDatum) => {
const id = idOf(datum);
ids.add(id);
const shape = element!.getElement(id);
if (!shape) return;
const cloneShape = this.shapes.get(id) || shape.cloneNode();
cloneShape.setPosition(shape.getPosition());
cloneShape.id = shape.id;
if (!this.shapes.has(id)) {
this.canvas.appendChild(cloneShape);
this.shapes.set(id, cloneShape);
} else {
Object.entries(shape.attributes).forEach(([key, value]) => {
if (cloneShape.style[key] !== value) cloneShape.style[key] = value;
});
}
const elementType = graph.getElementType(id) as Exclude;
const style = this.getElementStyle(elementType, datum);
// @ts-ignore
cloneShape.update(style);
};
nodes.forEach(iterate);
edges.forEach(iterate);
this.shapes.forEach((shape, id) => {
if (!ids.has(id)) {
shape.destroy();
this.shapes.delete(id);
}
});
};
private getElementStyle(elementType: ElementType, datum: ElementDatum) {
const styler = elementType === 'node' ? this.options.nodeStyle : this.options.edgeStyle;
if (typeof styler === 'function') return styler(datum as any);
return styler;
}
private scaleRByWheel = (event: WheelEvent) => {
if (this.options.preventDefault) event.preventDefault();
const { clientX, clientY, deltaX, deltaY } = event;
const { graph, canvas } = this.context;
const scaleOrigin = graph.getCanvasByClient([clientX, clientY]);
const origin = this.lens?.getCenter();
if (!this.isLensOn || distance(scaleOrigin, origin) > this.r) {
return;
}
const { maxR, minR } = this.options;
const ratio = deltaX + deltaY > 0 ? 1 / (1 - DELTA) : 1 - DELTA;
const canvasR = Math.min(...canvas.getSize()) / 2;
this.r = Math.max(minR || 0, Math.min(maxR || canvasR, this.r * ratio));
this.renderLens(origin);
this.renderFocusElements();
};
get graphDom() {
return this.context.graph.getCanvas().getContextService().getDomElement();
}
private isLensDragging = false;
private onDragStart = (event: IDragEvent) => {
const dragOrigin = parsePoint(event.canvas as PointObject);
const origin = this.lens?.getCenter();
if (!this.isLensOn || distance(dragOrigin, origin) > this.r) return;
this.isLensDragging = true;
};
private onDrag = (event: IDragEvent) => {
if (!this.isLensDragging) return;
const dragOrigin = parsePoint(event.canvas as PointObject);
this.renderLens(dragOrigin);
this.renderFocusElements();
};
private onDragEnd = () => {
this.isLensDragging = false;
};
private bindEvents() {
const { graph } = this.context;
const { trigger, scaleRBy } = this.options;
const canvas = graph.getCanvas().getLayer();
if (['click', 'drag'].includes(trigger)) {
canvas.addEventListener(CommonEvent.CLICK, this.onEdgeFilter);
}
if (trigger === 'pointermove') {
canvas.addEventListener(CommonEvent.POINTER_MOVE, this.onEdgeFilter);
} else if (trigger === 'drag') {
canvas.addEventListener(CommonEvent.DRAG_START, this.onDragStart);
canvas.addEventListener(CommonEvent.DRAG, this.onDrag);
canvas.addEventListener(CommonEvent.DRAG_END, this.onDragEnd);
}
if (scaleRBy === 'wheel') {
this.graphDom?.addEventListener(CommonEvent.WHEEL, this.scaleRByWheel, { passive: false });
}
}
private unbindEvents() {
const { graph } = this.context;
const { trigger, scaleRBy } = this.options;
const canvas = graph.getCanvas().getLayer();
if (['click', 'drag'].includes(trigger)) {
canvas.removeEventListener(CommonEvent.CLICK, this.onEdgeFilter);
}
if (trigger === 'pointermove') {
canvas.removeEventListener(CommonEvent.POINTER_MOVE, this.onEdgeFilter);
} else if (trigger === 'drag') {
canvas.removeEventListener(CommonEvent.DRAG_START, this.onDragStart);
canvas.removeEventListener(CommonEvent.DRAG, this.onDrag);
canvas.removeEventListener(CommonEvent.DRAG_END, this.onDragEnd);
}
if (scaleRBy === 'wheel') {
this.graphDom?.removeEventListener(CommonEvent.WHEEL, this.scaleRByWheel);
}
}
public update(options: Partial) {
this.unbindEvents();
super.update(options);
this.r = options.r ?? this.r;
this.bindEvents();
}
public destroy() {
this.unbindEvents();
if (this.isLensOn) {
this.lens.destroy();
}
this.shapes.forEach((shape, id) => {
shape.destroy();
this.shapes.delete(id);
});
super.destroy();
}
}