feat: processParallelEdges

This commit is contained in:
yvonneyx 2023-09-11 13:26:30 +08:00
parent ae8713ac05
commit b516769927
13 changed files with 481 additions and 21 deletions

View File

@ -46,7 +46,7 @@
"fix": "eslint ./src ./tests --fix && prettier ./src ./tests --write ",
"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_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/data-process-parallel-edges.spec.ts --config jest.node.config.js --coverage -i --logHeapUsage --detectOpenHandles",
"size": "limit-size",
"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"

View File

@ -1,3 +1,4 @@
export * from './mapNodeSize';
export * from './transformV4Data';
export * from './validateData';
export * from './processParallelEdges';

View File

@ -0,0 +1,108 @@
import { filter, mix } from '@antv/util';
import { loopPosition } from '../../util/loop';
import { GraphData, EdgeUserModel, ID } from '../../types';
/**
* Process edges which might overlap. For edges that share the same target and source nodes.
* @param data input user data.
* @param options.edgeIds The specific edge IDs to process, all edges by default.
* @param options.offsetDiff The offset between two parallel edges, 15 by default.
* @param options.multiEdgeType The edge type for the parallel edges, 'quadratic' by default. You can assign any custom edge type based on 'quadratic' to it.
* @param options.singleEdgeType The edge type for the single edge between two nodes, undefined by default, which means the type of the edge is kept unchanged as it is in the input data.
* @param options.loopEdgeType The edge type for a self-loop edge, undefined by default, which means the type of the edge is kept unchanged as it is in the input data.
* @returns formatted data.
*/
export const ProcessParallelEdges = (
data: GraphData,
options: {
edgeIds?: ID[];
offsetDiff?: number;
multiEdgeType?: string;
singleEdgeType?: string;
loopEdgeType?: string;
} = {},
): GraphData => {
const {
edgeIds = [],
offsetDiff = 15,
multiEdgeType = 'quadratic-edge',
singleEdgeType = undefined,
loopEdgeType = undefined,
} = options;
const edges =
edgeIds.length > 0
? filter(data.edges, (edge) => edgeIds.includes(edge.id))
: data.edges;
const len = edges.length;
const cod = offsetDiff * 2;
const edgeMap = new Map<string, EdgeUserModel[]>();
const processedEdgesSet = new Set<number>();
const reverses = {};
for (let i = 0; i < len; i++) {
if (processedEdgesSet.has(i)) continue;
const edge = edges[i];
const { source, target } = edge;
const sourceTarget = `${source}-${target}`;
if (!edgeMap.has(sourceTarget)) edgeMap.set(sourceTarget, []);
edgeMap.get(sourceTarget)!.push(edge);
processedEdgesSet.add(i);
for (let j = i + 1; j < len; j++) {
if (processedEdgesSet.has(j)) continue;
const sedge = edges[j];
const src = sedge.source;
const dst = sedge.target;
if (
(source === dst && target === src) ||
(source === src && target === dst)
) {
edgeMap.get(sourceTarget)!.push(sedge);
processedEdgesSet.add(j);
if (source === dst && target === src) {
reverses[`${src}|${dst}|${edgeMap.get(sourceTarget)?.length - 1}`] =
true;
}
}
}
}
edgeMap.forEach((arcEdges, key) => {
const { length } = arcEdges;
for (let k = 0; k < length; k++) {
const current = arcEdges[k];
current.data ||= {};
if (current.source === current.target) {
if (loopEdgeType) current.data.type = loopEdgeType;
current.data.keyShape ||= {};
// @ts-ignore
current.data.keyShape.loopCfg = {
position: loopPosition[k % 8],
dist: Math.floor(k / 8) * 20 + 50,
};
} else if (length === 1) {
if (singleEdgeType) current.data.type = singleEdgeType;
} else {
current.data.type = multiEdgeType;
const sign =
(k % 2 === 0 ? 1 : -1) *
(reverses[`${current.source}|${current.target}|${k}`] ? -1 : 1);
current.data.keyShape ||= {};
// @ts-ignore
current.data.keyShape.curveOffset =
length % 2 === 1
? sign * Math.ceil(k / 2) * cod
: sign * (Math.floor(k / 2) * cod + offsetDiff);
}
}
});
return { ...data, edges: mix(data.edges, edges) };
};

View File

@ -13,7 +13,8 @@ import * as Themes from './theme';
import * as ThemeSolvers from './themeSolver';
import * as Plugins from './plugin';
const { ValidateData, TransformV4Data, MapNodeSize } = Transforms;
const { ValidateData, TransformV4Data, MapNodeSize, ProcessParallelEdges } =
Transforms;
const { compactBox, dendrogram, indented, mindmap } = Hierarchy;
@ -207,6 +208,7 @@ const Extensions = {
ValidateData,
TransformV4Data,
MapNodeSize,
ProcessParallelEdges,
//themes
LightTheme,
DarkTheme,

View File

@ -2,6 +2,17 @@ import { Tuple3Number } from '@antv/g';
import { vec2 } from '@antv/matrix-util';
import { LOOP_POSITION, LoopPosition } from '../types/loop';
export const loopPosition = [
'top',
'top-right',
'right',
'bottom-right',
'bottom',
'bottom-left',
'left',
'top-left',
];
const PI_OVER_8 = Math.PI / 8;
const radiansMap = {

View File

@ -0,0 +1,104 @@
import { TestCaseContext } from '../interface';
import { Extensions, Graph, extend } from '../../../src/index';
const data = {
nodes: [
{
id: 'node1',
data: {
x: 50,
y: 350,
label: 'A',
},
},
{
id: 'node2',
data: {
x: 250,
y: 150,
label: 'B',
},
},
{
id: 'node3',
data: { x: 450, y: 350, label: 'C' },
},
],
edges: [
{
id: 'edge1',
source: 'node1',
target: 'node3',
data: {
label: `A -> C`,
},
},
{
id: 'edge2',
source: 'node3',
target: 'node1',
data: {
label: `C -> A`,
},
},
],
};
for (let i = 0; i < 10; i++) {
data.edges.push({
id: `edgeA-B${i}`,
source: 'node1',
target: 'node2',
data: {
label: `${i}th edge of A -> B`,
},
});
}
for (let i = 0; i < 5; i++) {
data.edges.push({
id: `edgeB-C${i}`,
source: 'node2',
target: 'node3',
data: {
label: `${i}th edge of B -> C`,
},
});
}
export default (context: TestCaseContext) => {
const ExtGraph = extend(Graph, {
transforms: {
'process-parallel-edges': Extensions.ProcessParallelEdges,
},
edges: {
'quadratic-edge': Extensions.QuadraticEdge,
'loop-edge': Extensions.LoopEdge,
},
});
const graph = new ExtGraph({
...context,
data,
transforms: [
{
type: 'process-parallel-edges',
multiEdgeType: 'quadratic-edge',
loopEdgeType: 'loop-edge',
},
],
modes: {
default: ['drag-node'],
},
node: {
labelShape: {
text: {
fields: ['label'],
formatter: (model) => model.data.label,
},
position: 'center',
},
},
});
return graph;
};

View File

@ -3,14 +3,14 @@ import anchor from './item/anchor';
import animations_node_build_in from './animations/node-build-in';
import arrow from './item/edge/arrow';
import behaviors_activateRelations from './behaviors/activate-relations';
import behaviors_shortcuts_call from './behaviors/shortcuts-call';
import behaviors_brush_select from './behaviors/brush-select';
import behaviors_click_select from './behaviors/click-select';
import behaviors_collapse_expand_tree from './behaviors/collapse-expand-tree';
import behaviors_shortcuts_call from './behaviors/shortcuts-call';
import circularUpdate from './layouts/circular-update';
import comboBasic from './combo/combo-basic';
import comboRect from './combo/combo-rect';
import comboDagre from './layouts/dagre-combo';
import comboRect from './combo/combo-rect';
import cubic_edge from './item/edge/cubic-edge';
import cubic_horizon_edge from './item/edge/cubic-horizontal-edge';
import cubic_vertical_edge from './item/edge/cubic-vertical-edge';
@ -19,14 +19,16 @@ import demo from './demo/demo';
import demoForPolyline from './demo/demoForPolyline';
import diamond from './demo/diamond';
import donut_node from './item/node/donut-node';
import image_node from './item/node/image';
import image_clip_node from './item/node/image-clip';
import ellipse from './demo/ellipse';
import fisheye from './plugins/fisheye';
import hexagon from './demo/hexagon';
import history from './plugins/history';
import history_combo from './plugins/history-combo';
import hull from './plugins/hull';
import image_clip_node from './item/node/image-clip';
import image_node from './item/node/image';
import layouts_circular from './layouts/circular';
import layouts_combocombined from './layouts/combo-combined';
import layouts_custom from './layouts/custom';
import layouts_d3force from './layouts/d3force';
import layouts_dagre from './layouts/dagre';
@ -38,27 +40,26 @@ import layouts_forceatlas2_wasm from './layouts/forceatlas2-wasm';
import layouts_fruchterman_gpu from './layouts/fruchterman-gpu';
import layouts_fruchterman_wasm from './layouts/fruchterman-wasm';
import layouts_grid from './layouts/grid';
import legend from './plugins/legend';
import line_edge from './item/edge/line-edge';
import loop_edge from './item/edge/loop-edge';
import menu from './demo/menu';
import modelRect from './demo/modelRect';
import performance from './performance/performance';
import performance_layout from './performance/layout';
import performance_layout_3d from './performance/layout-3d';
import star from './demo/star';
import polyline from './item/edge/polyline-edge';
import processParallelEdges from './data/process-parallel-edges';
import quadratic from './demo/quadratic';
import rect from './demo/rect';
import snapline from './plugins/snapline';
import star from './demo/star';
import toolbar from './plugins/toolbar';
import tooltip from './demo/tooltip';
import treeGraph from './tree/treeGraph';
import triangle from './demo/triangle';
import user_defined_canvas from './user-defined-canvas/circular';
import visual from './visual/visual';
import modelRect from './demo/modelRect';
import layouts_combocombined from './layouts/combo-combined';
import hull from './plugins/hull';
import legend from './plugins/legend';
import snapline from './plugins/snapline';
export { default as timebar_time } from './plugins/timebar-time';
export { default as timebar_chart } from './plugins/timebar-chart';
@ -68,12 +69,13 @@ export {
animations_node_build_in,
arrow,
behaviors_activateRelations,
behaviors_shortcuts_call,
behaviors_brush_select,
behaviors_click_select,
behaviors_collapse_expand_tree,
behaviors_shortcuts_call,
circularUpdate,
comboBasic,
comboDagre,
comboRect,
cubic_edge,
cubic_horizon_edge,
@ -83,15 +85,16 @@ export {
demoForPolyline,
diamond,
donut_node,
image_node,
image_clip_node,
ellipse,
fisheye,
hexagon,
history_combo,
history,
layouts_combocombined,
hull,
image_clip_node,
image_node,
layouts_circular,
layouts_combocombined,
layouts_custom,
layouts_d3force,
layouts_dagre,
@ -103,15 +106,19 @@ export {
layouts_fruchterman_gpu,
layouts_fruchterman_wasm,
layouts_grid,
legend,
line_edge,
loop_edge,
menu,
modelRect,
performance_layout_3d,
performance_layout,
performance,
polyline,
processParallelEdges,
quadratic,
rect,
snapline,
star,
toolbar,
tooltip,
@ -119,9 +126,4 @@ export {
triangle,
user_defined_canvas,
visual,
modelRect,
comboDagre,
hull,
legend,
snapline,
};

View File

@ -0,0 +1,103 @@
import { resetEntityCounter } from '@antv/g';
import processParallelEdges from '../demo/data/process-parallel-edges';
import { EdgeUserModel } from '../../src';
import { createContext } from './utils';
import './utils/useSnapshotMatchers';
describe('Items edge line', () => {
beforeEach(() => {
/**
* SVG Snapshot testing will generate a unique id for each element.
* Reset to 0 to keep snapshot consistent.
*/
resetEntityCounter();
});
it('should be rendered correctly with Canvas2D', (done) => {
const dir = `${__dirname}/snapshots/canvas`;
const { backgroundCanvas, canvas, transientCanvas, container } =
createContext('canvas', 500, 500);
const graph = processParallelEdges({
container,
backgroundCanvas,
canvas,
transientCanvas,
width: 500,
height: 500,
});
graph.on('afterlayout', async () => {
await expect(canvas).toMatchCanvasSnapshot(
dir,
'data-parallel-edges-quadratic',
);
const loopEdges: EdgeUserModel[] = [];
for (let i = 0; i < 10; i++) {
loopEdges.push({
id: `edgeA-A${i}`,
source: 'node1',
target: 'node1',
data: {
label: `${i}th edge of A -> A`,
},
});
}
graph.addData('edge', loopEdges);
await expect(canvas).toMatchCanvasSnapshot(
dir,
'data-parallel-edges-loop',
);
graph.destroy();
done();
});
});
it('should be rendered correctly with SVG', (done) => {
const dir = `${__dirname}/snapshots/svg`;
const { backgroundCanvas, canvas, transientCanvas, container } =
createContext('svg', 500, 500);
const graph = processParallelEdges({
container,
backgroundCanvas,
canvas,
transientCanvas,
width: 500,
height: 500,
});
graph.on('afterlayout', async () => {
await expect(canvas).toMatchSVGSnapshot(
dir,
'data-parallel-edges-quadratic',
);
graph.destroy();
done();
});
});
it.skip('should be rendered correctly with WebGL', (done) => {
const dir = `${__dirname}/snapshots/webgl`;
const { backgroundCanvas, canvas, transientCanvas, container } =
createContext('webgl', 500, 500);
const graph = processParallelEdges({
container,
backgroundCanvas,
canvas,
transientCanvas,
width: 500,
height: 500,
});
graph.on('afterlayout', async () => {
await expect(canvas).toMatchWebGLSnapshot(dir, 'items-edge-line');
graph.destroy();
done();
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,16 @@
{
"title": {
"zh": "中文分类",
"en": "Category"
},
"demos": [
{
"filename": "multiEdges.js",
"title": {
"zh": "两节点间存在多条边",
"en": "Multiple Edges Between 2 Nodes"
},
"screenshot": "https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*g2p_Qa_wZcIAAAAAAAAAAABkARQnAQ"
}
]
}

View File

@ -0,0 +1,112 @@
import { Graph, Extensions, extend } from '@antv/g6';
const data = {
nodes: [
{
id: 'node1',
data: {
x: 50,
y: 350,
label: 'A',
},
},
{
id: 'node2',
data: {
x: 250,
y: 150,
label: 'B',
},
},
{
id: 'node3',
data: { x: 450, y: 350, label: 'C' },
},
],
edges: [
{
id: 'edge1',
source: 'node1',
target: 'node3',
data: {
label: `A -> C`,
},
},
{
id: 'edge2',
source: 'node3',
target: 'node1',
data: {
label: `C -> A`,
},
},
],
};
for (let i = 0; i < 10; i++) {
data.edges.push({
id: `edgeA-B${i}`,
source: 'node1',
target: 'node2',
data: {
label: `${i}th edge of A -> B`,
},
});
}
for (let i = 0; i < 5; i++) {
data.edges.push({
id: `edgeB-C${i}`,
source: 'node2',
target: 'node3',
data: {
label: `${i}th edge of B -> C`,
},
});
}
const ExtGraph = extend(Graph, {
transforms: {
'process-parallel-edges': Extensions.ProcessParallelEdges,
},
edges: {
'quadratic-edge': Extensions.QuadraticEdge,
'loop-edge': Extensions.LoopEdge,
},
});
const width = container.scrollWidth;
const height = container.scrollHeight || 500;
const graph = new ExtGraph({
container: 'container',
width,
height,
data,
transforms: [
{
type: 'process-parallel-edges',
multiEdgeType: 'quadratic-edge',
loopEdgeType: 'loop-edge',
},
],
modes: {
default: ['drag-node'],
},
node: {
labelShape: {
text: {
fields: ['label'],
formatter: (model) => model.data.label,
},
position: 'center',
},
},
});
if (typeof window !== 'undefined')
window.onresize = () => {
if (!graph || graph.destroyed) return;
if (!container || !container.scrollWidth || !container.scrollHeight) return;
graph.setSize([container.scrollWidth, container.scrollHeight]);
};