Merge pull request #4989 from ColinChen2/feature/edge-filter-lens

Feature/edge filter lens
This commit is contained in:
Yanyan Wang 2023-09-26 09:45:56 +08:00 committed by GitHub
commit 20bb27cffa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 625 additions and 2 deletions

View File

@ -46,7 +46,7 @@
"fix": "eslint ./src ./tests --fix && prettier ./src ./tests --write ", "fix": "eslint ./src ./tests --fix && prettier ./src ./tests --write ",
"test": "jest", "test": "jest",
"test:integration": "node --expose-gc --max-old-space-size=4096 --unhandled-rejections=strict node_modules/jest/bin/jest tests/integration/ --config jest.node.config.js --coverage -i --logHeapUsage --detectOpenHandles", "test:integration": "node --expose-gc --max-old-space-size=4096 --unhandled-rejections=strict node_modules/jest/bin/jest tests/integration/ --config jest.node.config.js --coverage -i --logHeapUsage --detectOpenHandles",
"test:integration_one": "node --expose-gc --max-old-space-size=4096 --unhandled-rejections=strict node_modules/jest/bin/jest tests/integration/items-edge-loop.spec.ts --config jest.node.config.js --coverage -i --logHeapUsage --detectOpenHandles", "test:integration_one": "node --expose-gc --max-old-space-size=4096 --unhandled-rejections=strict node_modules/jest/bin/jest tests/integration/plugins-edgeFilterLens.spec.ts --config jest.node.config.js --coverage -i --logHeapUsage --detectOpenHandles",
"size": "limit-size", "size": "limit-size",
"test-live": "DEBUG_MODE=1 jest --watch ./tests/unit/item-animate-spec.ts", "test-live": "DEBUG_MODE=1 jest --watch ./tests/unit/item-animate-spec.ts",
"test-behavior": "DEBUG_MODE=1 jest --watch ./tests/unit/item-3d-spec.ts" "test-behavior": "DEBUG_MODE=1 jest --watch ./tests/unit/item-3d-spec.ts"

View File

@ -77,6 +77,7 @@ const {
Toolbar, Toolbar,
Timebar, Timebar,
Snapline, Snapline,
EdgeFilterLens,
} = Plugins; } = Plugins;
const { const {
@ -292,6 +293,7 @@ const Extensions = {
Timebar, Timebar,
Hull, Hull,
Snapline, Snapline,
EdgeFilterLens,
WaterMarker WaterMarker
}; };

View File

@ -0,0 +1,380 @@
import { DisplayObject } from '@antv/g';
import { clone } from '@antv/util';
import { Plugin as Base, IPluginBaseConfig } from '../../../types/plugin';
import { IGraph } from '../../../types';
import { IG6GraphEvent } from '../../../types/event';
import { ShapeStyle } from '../../../types/item';
import { distance } from '../../../util/point';
const DELTA = 0.01;
interface EdgeFilterLensConfig extends IPluginBaseConfig {
trigger?: 'mousemove' | 'click' | 'drag';
r?: number;
delegateStyle?: ShapeStyle;
showLabel?: 'node' | 'edge' | 'both' | undefined;
scaleRBy?: 'wheel' | undefined;
maxR?: number;
minR?: number;
showType?: 'one' | 'both' | 'only-source' | 'only-target'; // 更名原名与plugin的type冲突
shouldShow?: (d?: unknown) => boolean;
}
const lensDelegateStyle = {
stroke: '#000',
strokeOpacity: 0.8,
lineWidth: 2,
fillOpacity: 0.1,
fill: '#fff',
};
export class EdgeFilterLens extends Base {
private showNodeLabel: boolean;
private showEdgeLabel: boolean;
private delegate: DisplayObject;
private cachedTransientNodes: Set<string | number>;
private cachedTransientEdges: Set<string | number>;
private dragging: boolean;
private delegateCenterDiff: { x: number; y: number };
constructor(config?: EdgeFilterLensConfig) {
super(config);
this.cachedTransientNodes = new Set();
this.cachedTransientEdges = new Set();
}
public getDefaultCfgs(): EdgeFilterLensConfig {
return {
showType: 'both',
trigger: 'mousemove',
r: 60,
delegateStyle: clone(lensDelegateStyle),
showLabel: 'edge',
scaleRBy: 'wheel',
};
}
public getEvents() {
let events = {
pointerdown: this.onPointerDown,
pointerup: this.onPointerUp,
wheel: this.onWheel,
} as {
[key: string]: any;
};
switch (this.options.trigger) {
case 'click':
events = {
...events,
click: this.filter,
};
break;
case 'drag':
events = {
...events,
pointermove: this.onPointerMove,
};
break;
default:
events = {
...events,
pointermove: this.filter,
};
break;
}
return events;
}
public init(graph: IGraph) {
super.init(graph);
const showLabel = this.options.showLabel;
const showNodeLabel = showLabel === 'node' || showLabel === 'both';
const showEdgeLabel = showLabel === 'edge' || showLabel === 'both';
this.showNodeLabel = showNodeLabel;
this.showEdgeLabel = showEdgeLabel;
const shouldShow = this.options.shouldShow;
if (!shouldShow) this.options.shouldShow = () => true;
}
protected onPointerUp(e: IG6GraphEvent) {
this.dragging = false;
}
protected onPointerMove(e: IG6GraphEvent) {
if (!this.dragging) return;
this.moveDelegate(e);
}
protected onPointerDown(e: IG6GraphEvent) {
const { delegate: lensDelegate } = this;
let cacheCenter;
if (!lensDelegate || lensDelegate.destroyed) {
cacheCenter = { x: e.canvas.x, y: e.canvas.y };
this.filter(e);
} else {
cacheCenter = {
x: lensDelegate.style.cx,
y: lensDelegate.style.cy,
};
}
this.delegateCenterDiff = {
x: e.canvas.x - cacheCenter.x,
y: e.canvas.y - cacheCenter.y,
};
this.dragging = true;
}
// Determine whether it is dragged in the delegate
protected isInLensDelegate(lensDelegate, pointer): boolean {
const { cx: lensX, cy: lensY, r: lensR } = lensDelegate.style;
if (
pointer.x >= lensX - lensR &&
pointer.x <= lensX + lensR &&
pointer.y >= lensY - lensR &&
pointer.y <= lensY + lensR
) {
return true;
}
return false;
}
protected moveDelegate(e) {
if (
this.isInLensDelegate(this.delegate, { x: e.canvas.x, y: e.canvas.y })
) {
const center = {
x: e.canvas.x - this.delegateCenterDiff.x,
y: e.canvas.y - this.delegateCenterDiff.y,
};
this.filter(e, center);
}
}
protected onWheel(e: IG6GraphEvent) {
const { delegate: lensDelegate, options } = this;
const { scaleRBy } = options;
if (!lensDelegate || lensDelegate.destroyed) return;
if (scaleRBy !== 'wheel') return;
if (this.isInLensDelegate(lensDelegate, { x: e.canvas.x, y: e.canvas.y })) {
if (scaleRBy === 'wheel') {
this.scaleRByWheel(e);
}
}
}
/**
* Scale the range by wheel
* @param e mouse wheel event
*/
protected scaleRByWheel(e: IG6GraphEvent) {
if (!e || !e.originalEvent) return;
if (e.preventDefault) e.preventDefault();
const { graph, options } = this;
const graphCanvasEl = graph.canvas.context.config.canvas;
const graphHeight = graphCanvasEl?.height || 500;
const maxR = options.maxR
? Math.min(options.maxR, graphHeight)
: graphHeight;
const minR = options.minR
? Math.max(options.minR, graphHeight * DELTA)
: graphHeight * DELTA;
const scale = 1 + (e.originalEvent as any).deltaY * -1 * DELTA;
let r = options.r * scale;
r = Math.min(r, maxR);
r = Math.max(r, minR);
options.r = r;
this.delegate.style.r = r;
this.filter(e);
}
/**
* Response function for mousemove, click, or drag to filter out the edges
* @param e mouse event
*/
protected filter(e: IG6GraphEvent, mousePos?) {
const {
graph,
options,
showNodeLabel,
showEdgeLabel,
cachedTransientNodes,
cachedTransientEdges,
} = this;
const r = options.r;
const showType = options.showType;
const shouldShow = options.shouldShow;
const fCenter = mousePos || { x: e.canvas.x, y: e.canvas.y };
this.updateDelegate(fCenter, r);
const nodes = graph.getAllNodesData();
const hitNodesMap = {};
nodes.forEach((node) => {
const { data, id } = node;
if (distance({ x: data.x, y: data.y }, fCenter) < r) {
hitNodesMap[id] = node;
}
});
const edges = graph.getAllEdgesData();
const hitEdges = [];
edges.forEach((edge) => {
const sourceId = edge.source;
const targetId = edge.target;
if (shouldShow(edge)) {
if (showType === 'only-source' || showType === 'one') {
if (hitNodesMap[sourceId] && !hitNodesMap[targetId])
hitEdges.push(edge);
} else if (showType === 'only-target' || showType === 'one') {
if (hitNodesMap[targetId] && !hitNodesMap[sourceId])
hitEdges.push(edge);
} else if (
showType === 'both' &&
hitNodesMap[sourceId] &&
hitNodesMap[targetId]
) {
hitEdges.push(edge);
}
}
});
const currentTransientNodes = new Set<string | number>();
const currentTransientEdges = new Set<string | number>();
if (showNodeLabel) {
Object.keys(hitNodesMap).forEach((key) => {
const node = hitNodesMap[key];
currentTransientNodes.add(node.id);
if (cachedTransientNodes.has(node.id)) {
cachedTransientNodes.delete(node.id);
} else {
graph.drawTransient('node', node.id, { shapeIds: ['labelShape'] });
}
});
cachedTransientNodes.forEach((id) => {
graph.drawTransient('node', id, { action: 'remove' });
});
}
if (showEdgeLabel) {
hitEdges.forEach((edge) => {
currentTransientEdges.add(edge.id);
if (cachedTransientEdges.has(edge.id)) {
cachedTransientEdges.delete(edge.id);
} else {
graph.drawTransient('edge', edge.id, {
shapeIds: ['labelShape'],
drawSource: false,
drawTarget: false,
});
}
});
cachedTransientEdges.forEach((id) => {
graph.drawTransient('edge', id, { action: 'remove' });
});
}
this.cachedTransientNodes = currentTransientNodes;
this.cachedTransientEdges = currentTransientEdges;
}
/**
* Adjust part of the parameters, including trigger, showType, r, maxR, minR, shouldShow, showLabel, and scaleRBy
* @param {EdgeFilterLensConfig} cfg
*/
public updateParams(cfg: EdgeFilterLensConfig) {
const self = this;
const { r, trigger, minR, maxR, scaleRBy, showLabel, shouldShow } = cfg;
if (!isNaN(cfg.r)) {
self.options.r = r;
}
if (!isNaN(maxR)) {
self.options.maxR = maxR;
}
if (!isNaN(minR)) {
self.options.minR = minR;
}
if (trigger === 'mousemove' || trigger === 'click' || trigger === 'drag') {
self.options.trigger = trigger;
}
if (scaleRBy === 'wheel' || scaleRBy === 'unset') {
self.options.scaleRBy = scaleRBy;
self.delegate.remove();
self.delegate.destroy();
}
if (showLabel === 'node' || showLabel === 'both') {
self.showNodeLabel = true;
}
if (showLabel === 'edge' || showLabel === 'both') {
self.showEdgeLabel = true;
}
if (shouldShow) {
self.options.shouldShow = shouldShow;
}
}
/**
* Update the delegate shape of the lens
* @param {Point} mCenter the center of the shape
* @param {number} r the radius of the shape
*/
private updateDelegate(mCenter, r) {
const { graph, options, delegate } = this;
let lensDelegate = delegate;
if (!lensDelegate || lensDelegate.destroyed) {
// 拖动多个
const attrs = options.delegateStyle || lensDelegateStyle;
// model上的x, y是相对于图形中心的delegateShape是g实例x,y是绝对坐标
lensDelegate = graph.drawTransient('circle', 'lens-shape', {
style: {
r,
cx: mCenter.x,
cy: mCenter.y,
...attrs,
},
});
} else {
lensDelegate.style.cx = mCenter.x;
lensDelegate.style.cy = mCenter.y;
lensDelegate.style.r = mCenter.r;
}
this.delegate = lensDelegate;
}
/**
* Clear the filtering
*/
public clear() {
const {
graph,
delegate: lensDelegate,
cachedTransientNodes,
cachedTransientEdges,
} = this;
if (lensDelegate && !lensDelegate.destroyed) {
graph.drawTransient('circle', 'lens-shape', { action: 'remove' });
}
cachedTransientNodes.forEach((id) => {
graph.drawTransient('node', id, { action: 'remove' });
});
cachedTransientEdges.forEach((id) => {
graph.drawTransient('edge', id, { action: 'remove' });
});
cachedTransientNodes.clear();
cachedTransientEdges.clear();
}
/**
* Destroy the component
*/
public destroy() {
this.clear();
}
}

View File

@ -8,3 +8,4 @@ export * from './toolbar';
export * from './tooltip'; export * from './tooltip';
export * from './timebar'; export * from './timebar';
export * from './snapline'; export * from './snapline';
export * from './edgeFilterLens';

View File

@ -8,3 +8,4 @@ export type { ToolbarConfig } from './toolbar';
export type { TooltipConfig } from './tooltip'; export type { TooltipConfig } from './tooltip';
export type { TimebarConfig } from './timebar'; export type { TimebarConfig } from './timebar';
export type { SnapLineConfig } from './snapline'; export type { SnapLineConfig } from './snapline';
export type { EdgeFilterLens } from './edgeFilterLens';

View File

@ -64,6 +64,7 @@ import legend from './plugins/legend';
import snapline from './plugins/snapline'; import snapline from './plugins/snapline';
import mapper from './visual/mapper'; import mapper from './visual/mapper';
import minimap from './plugins/minimap'; import minimap from './plugins/minimap';
import edgeFilterLens from './plugins/edgeFilterLens';
import watermarker from './plugins/watermarker'; import watermarker from './plugins/watermarker';
import cube from './item/node/cube'; import cube from './item/node/cube';
import graphCore from './data/graphCore'; import graphCore from './data/graphCore';
@ -138,7 +139,8 @@ export {
hull, hull,
legend, legend,
snapline, snapline,
watermarker edgeFilterLens,
watermarker,
cube, cube,
graphCore, graphCore,
dagreUpdate, dagreUpdate,

View File

@ -0,0 +1,195 @@
import { Graph, Extensions, extend } from '../../../src/index';
import { TestCaseContext } from '../interface';
import data from '../../datasets/force-data.json';
import { clone } from '@antv/util';
export default (context: TestCaseContext, options = {}) => {
const trigger = 'mousemove';
let filterLens = {
type: 'filterLens',
key: 'filterLens1',
trigger,
showLabel: 'edge',
r: 140,
...options,
};
// ================= The DOMs for configurations =============== //
const graphDiv = document.getElementById('container');
const buttonContainer = document.createElement('div');
buttonContainer.style.display = 'inline-block';
buttonContainer.style.height = '35px';
buttonContainer.style.width = '100%';
buttonContainer.style.textAlign = 'center';
// tip
const tip = document.createElement('span');
tip.innerHTML =
'点击画布任意位置开始探索。过滤镜中显示两端节点均在过滤镜中的边。';
buttonContainer.appendChild(tip);
buttonContainer.appendChild(document.createElement('br'));
// tip english
const tipEn = document.createElement('span');
tipEn.innerHTML =
'Click the canvas to begin. Show the edge whose both end nodes are inside the lens.';
buttonContainer.appendChild(tipEn);
buttonContainer.appendChild(document.createElement('br'));
// enable/disable the fisheye lens button
const swithButton = document.createElement('input');
swithButton.type = 'button';
swithButton.value = 'Disable';
swithButton.style.height = '25px';
swithButton.style.width = '60px';
swithButton.style.marginLeft = '16px';
buttonContainer.appendChild(swithButton);
// list for changing trigger
const triggerTag = document.createElement('span');
triggerTag.innerHTML = 'Trigger:';
triggerTag.style.marginLeft = '16px';
buttonContainer.appendChild(triggerTag);
const configTrigger = document.createElement('select');
configTrigger.value = 'mousemove';
configTrigger.style.height = '25px';
configTrigger.style.width = '100px';
configTrigger.style.marginLeft = '8px';
const mousemoveTrigger = document.createElement('option');
mousemoveTrigger.value = 'mousemove';
mousemoveTrigger.innerHTML = 'mousemove';
configTrigger.appendChild(mousemoveTrigger);
const dragTrigger = document.createElement('option');
dragTrigger.value = 'drag';
dragTrigger.innerHTML = 'drag';
configTrigger.appendChild(dragTrigger);
const clickTrigger = document.createElement('option');
clickTrigger.value = 'click';
clickTrigger.innerHTML = 'click';
configTrigger.appendChild(clickTrigger);
buttonContainer.appendChild(configTrigger);
// list for changing scaleRBy
const scaleR = document.createElement('span');
scaleR.innerHTML = 'Scale r by:';
scaleR.style.marginLeft = '16px';
buttonContainer.appendChild(scaleR);
const configScaleRBy = document.createElement('select');
configScaleRBy.value = 'wheel';
configScaleRBy.style.height = '25px';
configScaleRBy.style.width = '100px';
configScaleRBy.style.marginLeft = '8px';
const scaleRByWheel = document.createElement('option');
scaleRByWheel.value = 'wheel';
scaleRByWheel.innerHTML = 'wheel';
configScaleRBy.appendChild(scaleRByWheel);
const scaleRByUnset = document.createElement('option');
scaleRByUnset.value = 'unset';
scaleRByUnset.innerHTML = 'unset';
configScaleRBy.appendChild(scaleRByUnset);
buttonContainer.appendChild(configScaleRBy);
graphDiv.parentNode.appendChild(buttonContainer);
// ========================================================= //
const ExtGraph = extend(Graph, {
plugins: {
filterLens: Extensions.EdgeFilterLens,
},
});
const graph = new ExtGraph({
...context,
layout: {
type: 'grid',
begin: [0, 0],
},
plugins: [filterLens],
node: (innerModel) => {
return {
...innerModel,
data: {
...innerModel.data,
lodStrategy: {
levels: [
{ zoomRange: [0, 0.9] }, // -1
{ zoomRange: [0.9, 1], primary: true }, // 0
{ zoomRange: [1, 1.2] }, // 1
{ zoomRange: [1.2, 1.5] }, // 2
{ zoomRange: [1.5, Infinity] }, // 3
],
animateCfg: {
duration: 500,
},
},
labelShape: {
text: innerModel.data.label,
lod: 1, // 图的缩放大于 levels 第一层定义的 zoomRange[0] 时展示,小于时隐藏
},
},
};
},
edge: (innerModel) => {
return {
...innerModel,
data: {
...innerModel.data,
lodStrategy: {
levels: [
{ zoomRange: [0, 0.9] }, // -1
{ zoomRange: [0.9, 1], primary: true }, // 0
{ zoomRange: [1, 1.2] }, // 1
{ zoomRange: [1.2, 1.5] }, // 2
{ zoomRange: [1.5, Infinity] }, // 3
],
animateCfg: {
duration: 500,
},
},
labelShape: {
text: innerModel.data.label,
maxWidth: '100%',
lod: 1, // 图的缩放大于 levels 第一层定义的 zoomRange[0] 时展示,小于时隐藏
},
},
};
},
modes: {
// default: ['drag-canvas',],
},
});
swithButton.addEventListener('click', (e) => {
if (swithButton.value === 'Disable') {
swithButton.value = 'Enable';
graph.removePlugins(['filterLens1']);
} else {
swithButton.value = 'Disable';
graph.addPlugins([filterLens]);
}
});
configScaleRBy.addEventListener('change', (e) => {
filterLens = {
...filterLens,
scaleRBy: e.target.value,
};
graph.updatePlugin(filterLens);
});
configTrigger.addEventListener('change', (e) => {
filterLens = {
...filterLens,
trigger: e.target.value,
};
graph.updatePlugin(filterLens);
});
const cloneData = clone(data);
cloneData.edges.forEach((edge) => (edge.data = { label: edge.id }));
graph.read(cloneData);
graph.zoom(0.6);
return graph;
};

View File

@ -0,0 +1,42 @@
import { resetEntityCounter } from '@antv/g';
import EdgeFilterLens from '../demo/plugins/edgeFilterLens';
import { createContext, sleep } from './utils';
import { triggerEvent } from './utils/event';
import './utils/useSnapshotMatchers';
describe('Default EdgeFilterLens', () => {
beforeEach(() => {
resetEntityCounter();
});
it('should be rendered correctly with fitler lens with mousemove', async () => {
const dir = `${__dirname}/snapshots/canvas/plugins/edgeFilterLens`;
const { backgroundCanvas, canvas, transientCanvas, container } =
createContext('canvas', 500, 500);
const graph = EdgeFilterLens({
backgroundCanvas,
canvas,
transientCanvas,
width: 500,
height: 500,
container,
});
console.log('graph', graph);
const process = new Promise((reslove) => {
graph.on('afterlayout', () => {
console.log('afterlayout');
reslove();
});
});
await process;
await sleep(300);
triggerEvent(graph, 'mousedown', 200, 200);
await expect(transientCanvas).toMatchCanvasSnapshot(
dir,
'plugins-edge-filter-lens-transients',
);
graph.destroy();
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB