feat(plugin): add edge filter lens plugin (#6224)

* feat(plugin): add edge filter lens

* test: add unit tests

* refactor(plugins): fix cr issues

* test: update unit test

* docs: add demo

* fix: update demo and tests
This commit is contained in:
Yuxin 2024-08-22 20:32:19 +08:00 committed by GitHub
parent b0ce104eb1
commit 835f7fe037
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 23058 additions and 2263 deletions

File diff suppressed because it is too large Load Diff

View File

@ -113,6 +113,7 @@ export { pluginBackground } from './plugin-background';
export { pluginBubbleSets } from './plugin-bubble-sets';
export { pluginCameraSetting } from './plugin-camera-setting';
export { pluginContextmenu } from './plugin-contextmenu';
export { pluginEdgeFilterLens } from './plugin-edge-filter-lens';
export { pluginFullscreen } from './plugin-fullscreen';
export { pluginGridLine } from './plugin-grid-line';
export { pluginHistory } from './plugin-history';

View File

@ -0,0 +1,33 @@
import data from '@@/dataset/relations.json';
import { Graph } from '@antv/g6';
export const pluginEdgeFilterLens: TestCase = async (context) => {
const graph = new Graph({
...context,
data,
node: {
style: { size: 16 },
palette: {
field: (datum) => Math.floor(Number(datum.style?.y) / 60),
},
},
edge: {
style: {
label: false,
labelText: (d) => d.data!.value?.toString(),
stroke: '#ccc',
},
},
plugins: [
{
key: 'edge-filter-lens',
type: 'edge-filter-lens',
},
],
autoFit: 'view',
});
await graph.render();
return graph;
};

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 158 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 159 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 162 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 162 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 162 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 160 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 160 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 160 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 162 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 162 KiB

View File

@ -0,0 +1,77 @@
import { pluginEdgeFilterLens } from '@@/demos';
import { CommonEvent, Graph } from '@antv/g6';
import { createDemoGraph, dispatchCanvasEvent } from '../../utils';
describe('edge-filter-lens', () => {
let graph: Graph;
beforeAll(async () => {
graph = await createDemoGraph(pluginEdgeFilterLens, { animation: false });
});
afterAll(() => {
graph.destroy();
});
it('move lens by pointermove', async () => {
await expect(graph).toMatchSnapshot(__filename);
dispatchCanvasEvent(graph, CommonEvent.POINTER_MOVE, { canvas: { x: 200, y: 100 } });
await expect(graph).toMatchSnapshot(__filename, 'move-lens-pointermove');
});
it('move lens by click', async () => {
graph.updatePlugin({ key: 'edge-filter-lens', trigger: 'click' });
dispatchCanvasEvent(graph, CommonEvent.CLICK, { canvas: { x: 180, y: 100 } });
await expect(graph).toMatchSnapshot(__filename, 'move-lens-click-1');
dispatchCanvasEvent(graph, CommonEvent.CLICK, { canvas: { x: 200, y: 100 } });
await expect(graph).toMatchSnapshot(__filename, 'move-lens-click-2');
});
it('move lens by drag', async () => {
graph.updatePlugin({ key: 'edge-filter-lens', trigger: 'drag' });
dispatchCanvasEvent(graph, CommonEvent.CLICK, { canvas: { x: 180, y: 100 } });
dispatchCanvasEvent(graph, CommonEvent.DRAG_START, { canvas: { x: 200, y: 100 } });
dispatchCanvasEvent(graph, CommonEvent.DRAG, { canvas: { x: 220, y: 100 } });
dispatchCanvasEvent(graph, CommonEvent.DRAG_END);
await expect(graph).toMatchSnapshot(__filename, 'move-lens-drag');
});
it('scale lens by wheel', async () => {
function emitWheelEvent(options?: { deltaX: number; deltaY: number; clientX: number; clientY: number }) {
const dom = graph.getCanvas().getContextService().getDomElement();
dom?.dispatchEvent(new WheelEvent(CommonEvent.WHEEL, options));
}
emitWheelEvent({ deltaX: 1, deltaY: 2, clientX: 200, clientY: 100 });
emitWheelEvent({ deltaX: 1, deltaY: 2, clientX: 200, clientY: 100 });
await expect(graph).toMatchSnapshot(__filename, 'scale-larger');
emitWheelEvent({ deltaX: -1, deltaY: -2, clientX: 200, clientY: 100 });
emitWheelEvent({ deltaX: -1, deltaY: -2, clientX: 200, clientY: 100 });
emitWheelEvent({ deltaX: -1, deltaY: -2, clientX: 200, clientY: 100 });
emitWheelEvent({ deltaX: -1, deltaY: -2, clientX: 200, clientY: 100 });
await expect(graph).toMatchSnapshot(__filename, 'scale-smaller');
});
it('show edge when only its source/target node in lens', async () => {
graph.updatePlugin({ key: 'edge-filter-lens', trigger: 'click', nodeType: 'source' });
dispatchCanvasEvent(graph, CommonEvent.CLICK, { canvas: { x: 200, y: 200 } });
await expect(graph).toMatchSnapshot(__filename, 'node-type-source');
graph.updatePlugin({ key: 'edge-filter-lens', trigger: 'click', nodeType: 'target' });
dispatchCanvasEvent(graph, CommonEvent.CLICK, { canvas: { x: 200, y: 200 } });
await expect(graph).toMatchSnapshot(__filename, 'node-type-target');
graph.updatePlugin({ key: 'edge-filter-lens', trigger: 'click', nodeType: 'either' });
dispatchCanvasEvent(graph, CommonEvent.CLICK, { canvas: { x: 200, y: 200 } });
await expect(graph).toMatchSnapshot(__filename, 'node-type-either');
});
});

View File

@ -67,6 +67,7 @@ export {
BubbleSets,
CameraSetting,
Contextmenu,
EdgeFilterLens,
Fullscreen,
GridLine,
History,
@ -168,6 +169,7 @@ export type {
BubbleSetsOptions,
CameraSettingOptions,
ContextmenuOptions,
EdgeFilterLensOptions,
FullscreenOptions,
GridLineOptions,
HistoryOptions,

View File

@ -0,0 +1,391 @@
import type { BaseStyleProps } from '@antv/g';
import { CommonEvent } from '../../constants';
import { Circle } 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 } from '../../utils/point';
import { positionOf } from '../../utils/position';
import { distance } from '../../utils/vector';
import type { BasePluginOptions } from '../base-plugin';
import { BasePlugin } from '../base-plugin';
export interface EdgeFilterLensOptions extends BasePluginOptions {
/**
* <zh/>
* - `'pointermove'`
* - `'click'`
* - `'drag'`
*
* <en/> 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';
/**
* <zh/>
*
* <en/> The radius of the lens
* @defaultValue 60
*/
r?: number;
/**
* <zh/> `scaleRByWheel`
*
* <en/> The maximum radius of the lens. Only valid when `scaleRByWheel` is enabled
* @defaultValue canvas
*/
maxR?: number;
/**
* <zh/> `scaleRByWheel`
*
* <en/> The minimum radius of the lens. Only valid when `scaleRByWheel` is enabled
* @defaultValue 0
*/
minR?: number;
/**
* <zh/>
*
* <en/> Whether to scale the radius of the lens by wheel
* @defaultValue true
*/
scaleRByWheel?: boolean;
/**
* <zh/>
* - `'both'`
* - `'source'`
* - `'target'`
* - `'either'`
*
* <zh/> 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';
/**
* <zh/>
*
* <en/> Filter elements that are never displayed in the lens
* @param id - <zh/> id | <en/> The id of the element
* @param elementType - <zh/> | <en/> The type of the element
* @returns <zh/> | <en/> Whether to display
*/
filter?: (id: ID, elementType: ElementType) => boolean;
/**
* <zh/>
*
* <en/> The style of the lens
*/
style?: BaseStyleProps;
/**
* <zh/>
*
* <en/> The style of the nodes displayed in the lens
*/
nodeStyle?: NodeStyle | ((datum: NodeData) => NodeStyle);
/**
* <zh/>
*
* <en/> The style of the edges displayed in the lens
*/
edgeStyle?: EdgeStyle | ((datum: EdgeData) => EdgeStyle);
/**
* <zh/>
*
* <en/> Whether to prevent the default event
* @defaultValue true
*/
preventDefault?: boolean;
}
const defaultLensStyle: BaseStyleProps = {
fill: '#fff',
fillOpacity: 1,
lineWidth: 1,
stroke: '#000',
strokeOpacity: 0.8,
};
const DELTA = 0.05;
export class EdgeFilterLens extends BasePlugin<EdgeFilterLensOptions> {
static defaultOptions: Partial<EdgeFilterLensOptions> = {
trigger: 'pointermove',
r: 60,
nodeType: 'both',
filter: () => true,
style: { lineWidth: 2 },
nodeStyle: { label: false },
edgeStyle: { label: true },
scaleRByWheel: true,
preventDefault: true,
};
constructor(context: RuntimeContext, options: EdgeFilterLensOptions) {
super(context, Object.assign({}, EdgeFilterLens.defaultOptions, options));
this.bindEvents();
}
private lens!: Circle;
private shapes = new Map<ID, Element>();
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 [x, y] = origin;
const positionStyle = { size: this.r * 2, x, y };
if (!this.isLensOn) {
const style = Object.assign({}, defaultLensStyle, this.options.style, positionStyle);
this.lens = new Circle({ style });
} else {
this.lens.update(positionStyle);
}
this.canvas.appendChild(this.lens);
};
private getFilterData = (): Required<GraphData> => {
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<ID>();
const { nodeStyle, edgeStyle } = this.options;
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<ElementType, 'combo'>;
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, scaleRByWheel } = 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 (scaleRByWheel) {
this.graphDom?.addEventListener(CommonEvent.WHEEL, this.scaleRByWheel, { passive: false });
}
}
private unbindEvents() {
const { graph } = this.context;
const { trigger, scaleRByWheel } = 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 (scaleRByWheel) {
this.graphDom?.removeEventListener(CommonEvent.WHEEL, this.scaleRByWheel);
}
}
public update(options: Partial<EdgeFilterLensOptions>) {
this.unbindEvents();
super.update(options);
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();
}
}

View File

@ -3,6 +3,7 @@ export { BasePlugin } from './base-plugin';
export { BubbleSets } from './bubble-sets';
export { CameraSetting } from './camera-setting';
export { Contextmenu } from './contextmenu';
export { EdgeFilterLens } from './edge-filter-lens';
export { Fullscreen } from './fullscreen';
export { GridLine } from './grid-line';
export { History } from './history';
@ -20,6 +21,7 @@ export type { BasePluginOptions } from './base-plugin';
export type { BubbleSetsOptions } from './bubble-sets';
export type { CameraSettingOptions } from './camera-setting';
export type { ContextmenuOptions } from './contextmenu';
export type { EdgeFilterLensOptions } from './edge-filter-lens';
export type { FullscreenOptions } from './fullscreen';
export type { GridLineOptions } from './grid-line';
export type { HistoryOptions } from './history';

View File

@ -59,6 +59,7 @@ import {
Background,
BubbleSets,
Contextmenu,
EdgeFilterLens,
Fullscreen,
GridLine,
History,
@ -169,6 +170,7 @@ const BUILT_IN_EXTENSIONS: ExtensionRegistry = {
},
plugin: {
'bubble-sets': BubbleSets,
'edge-filter-lens': EdgeFilterLens,
'grid-line': GridLine,
background: Background,
contextmenu: Contextmenu,

View File

@ -0,0 +1,67 @@
import { Graph } from '@antv/g6';
fetch('https://assets.antv.antgroup.com/g6/relations.json')
.then((res) => res.json())
.then((data) => {
const graph = new Graph({
container: 'container',
data,
autoFit: 'view',
node: {
style: { size: 16 },
palette: {
field: (datum) => Math.floor(datum.style?.y / 60),
},
},
edge: {
style: {
label: false,
labelText: (d) => d.data.value?.toString(),
stroke: '#ccc',
},
},
plugins: [
{
key: 'edge-filter-lens',
type: 'edge-filter-lens',
},
],
});
graph.render();
const config = {
trigger: 'pointermove',
scaleRByWheel: true,
nodeType: 'both',
};
window.addPanel((gui) => {
gui
.add(config, 'trigger', ['pointermove', 'click', 'drag'])
.name('Trigger')
.onChange((value) => {
graph.updatePlugin({
key: 'edge-filter-lens',
trigger: value,
});
});
gui
.add(config, 'scaleRByWheel')
.name('Scale R by Wheel')
.onChange((value) => {
graph.updatePlugin({
key: 'edge-filter-lens',
scaleRByWheel: value,
});
});
gui
.add(config, 'nodeType', ['source', 'target', 'both', 'either'])
.name('Node Type')
.onChange((value) => {
graph.updatePlugin({
key: 'edge-filter-lens',
nodeType: value,
});
});
});
});

View File

@ -0,0 +1,16 @@
{
"title": {
"zh": "中文分类",
"en": "Category"
},
"demos": [
{
"filename": "basic.js",
"title": {
"zh": "边过滤镜",
"en": "Edge Filter Lens"
},
"screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*62FuSY-LFEIAAAAAAAAAAAAADmJ7AQ/original"
}
]
}

View File

@ -0,0 +1,5 @@
---
title: EdgeFilterLens
---
EdgeFilterLens can keep the focused edges within the lens range, while other edges will not be displayed within that range.

View File

@ -0,0 +1,5 @@
---
title: EdgeFilterLens 边过滤镜
---
边过滤镜可以将关注的边保留在过滤镜范围内,其他边将在该范围内不显示。