feat: refactor G6

This commit is contained in:
聚则 2020-12-04 20:47:17 +08:00
parent a7ebea3352
commit bdcd19e1cc
194 changed files with 155893 additions and 0 deletions

26
packages/core/.babelrc.js Normal file
View File

@ -0,0 +1,26 @@
module.exports = api => {
api.cache(() => process.env.NODE_ENV);
if (process.env.GATSBY === 'true') {
return {
presets: ['@babel/preset-env', 'babel-preset-gatsby'],
};
}
return {
presets: [
[
'@babel/preset-env',
{
loose: true,
modules: false,
},
],
'@babel/preset-react',
{
"plugins": [
"@babel/plugin-proposal-class-properties"
]
}
]
};
};

View File

@ -0,0 +1,22 @@
build/
coverage/
lib/
dist/
mocks/
node_modules/
demos/
.cache
public
bin
esm/
es/
examples/
tests/
stories/
gatsby-browser.js
site/
webpack.*.js
gatsby-*.js
global.d.ts
jest.config.js
.eslintrc.*

View File

@ -0,0 +1,42 @@
module.exports = {
extends: [require.resolve('@umijs/fabric/dist/eslint')],
globals: {
$: true,
_: true,
},
rules: {
'no-bitwise': 0,
'import/order': 0,
'no-plusplus': 0,
'no-console': ['error', { allow: ['warn', 'error'] }],
'operator-assignment': 0,
'consistent-return': 0,
'lines-between-class-members': 0,
'class-methods-use-this': 0,
'lines-between-class-members': 0,
'no-multi-assign': 0,
'no-continue': 0,
'no-underscore-dangle': 0,
'no-useless-constructor': 0,
'prefer-destructuring': 0,
'guard-for-in': 0,
'no-restricted-globals': 0,
'max-classes-per-file': 0,
// 后面需要去掉
'no-restricted-syntax': 0,
'prefer-spread': 0,
'@typescript-eslint/camelcase': 0,
'no-loop-func': 0,
'@typescript-eslint/no-loop-func': 0,
'@typescript-eslint/no-redeclare': 0,
'@typescript-eslint/no-shadow': 0,
'@typescript-eslint/no-unused-vars': 0,
'no-param-reassign': 0,
'import/no-extraneous-dependencies': 0,
'no-unused-expressions': 0,
'dot-notation': 0,
'array-callback-return': 0,
'one-var': 0,
'no-lonely-if': 0
},
};

View File

@ -0,0 +1,5 @@
export default {
entry: './src/index.ts',
esm: 'babel',
cjs: 'babel',
};

83
packages/core/.npmignore Normal file
View File

@ -0,0 +1,83 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# lock
package-lock.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.DS_Store
# npmignore - content above this line is automatically generated and modifications may be omitted
# see npmjs.com/npmignore for more details.
test
*.sw*
*.un~
.idea
bin
demos
docs
temp
webpack-dev.config.js
webpack.config.js
public
.cache
site
examples
gatsby-browser.js
gatsby-config.js

View File

@ -0,0 +1,10 @@
.cache
package.json
package-lock.json
public
dist
es
lib
.*
*.png
**/assets/**

View File

@ -0,0 +1,5 @@
const fabric = require('@umijs/fabric');
module.exports = {
...fabric.prettier,
};

957
packages/core/CHANGELOG.md Normal file
View File

@ -0,0 +1,957 @@
# ChangeLog
#### 4.0.3
- fix: state style restore for non-circle shapes;
#### 4.0.2
- fix: node and edge state style with update problem;
- fix: import lib problem;
- fix: import node module problem;
- fix: hidden shapes show up after zoom-canvas or drag-canvas with enableOptimize;
- fix: tooltip for combo;
- fix: update edge with false endArrow and startArrow;
#### 4.0.1
- fix: glslang problem;
#### 4.0.0-beta.0
- feat: fruchterman and gforce layout with gpu;
- feat: gforce;
- feat: updateChildren API for TreeGraph;
- feat: louvain clustering algorithm;
- feat: container of plugins with dom id;
- feat: label propagation clustering algorithm;
- feat: get color sets by subject color array;
- feat: canvas context menu;
- feat: stop gforce;
- feat: dark rules for colors;
- fix: text redidual problem, closes: #2045 #2193;
- fix: graph on callback parameter type problem, closes: #2250;
- fix: combo zIndex problem;
- fix: webworker updateLayoutCfg problem;
- fix: drag-canvas and click node on mobile;
#### 3.8.5
- fix: get fontFamily of the window in global leads to DOM depending when using bigfish;
#### 3.8.4
- feat: new version of basic styles for light version;
- feat: shortcuts-call behavior for calling a Graph function by shortcuts;
- feat: color generate util function getColorsWithSubjectColor;
- fix: drag-canvas on mobile problem;
- fix: style update problem with stateStyles in the options of registerNode;
#### 3.8.3
- feat: drag the viewport of the minimap out of the the view;
- fix: extend modelRect with description problem, closes: #2235;
#### 3.8.2
- feat: graph.setImageWaterMarker, graph.setTextWaterMarker API;
- feat: zoom-canvas support mobile;
- fix: drag-canvas behavior support scalable range, closes: #2136;
- fix: TreeGraph changeData clear all states, closes: #2173;
- chore: auto zoom tooltip & contextMenu component when zoom-canvas;
- chore: upgrade @antv/g-canvas;
- feat: destroyLayout API for graph, closes: #2140;
- feat: clustering for force layout, closes: #2196;
- fix: svg renderer minimap hidden elements probem, closes: #2174;
- feat: add extra parameter graph for menu plugin, closes: #2204;
- fix: tooltip plugin, crossing different shape cant execute the getContent function, closes: #2153;
- feat: add edgeConfig for create-edge behavior, closes: #2195;
- fix: remove the source node while creat-edge;
- feat: create-edge for combo, closes: #2211;
- fix: update the typings for G6Event;
#### 3.8.1
- fix: update edge states with updateItem problem, closes: #2142;
- fix: create-edge behavior with polyline problem, closes: #2165;
- fix: console.warn show duplicate ID, closes: #2163;
- feat: support the drag-canvas behavior on the mobile device, closes: #816;
- chore: timeBar component docs;
#### 3.8.0
- fix: treeGraph render with addItem and stack problem, closes: #2084;
- feat: G6 Interactive Document GraphMarker;
- feat: registerNode with jsx support afterDraw and setState;
- feat: edge filter lens plugin;
- feat: timebar plugin;
#### 3.7.3
- fix: update G to fix the shape disappear when it has been dragged out of the view port problem, closes: #2078, #2030, #2007;
- fix: redo undo with treeGraph problem;
- fix: remove item with itemType problem, closes: #2096.
#### 3.7.2
- fix: toolbar redo undo addItem with type problem, closes #2043;
- fix: optimized drag-canvas with hidden items;
- fix: state style with 0 value problem, closes: #2039;
- fix: layout with webworker leads to twice beforelayout, closes: #2052;
- fix: context menu with sibling doms of graph container leads to position problem, closes: #2053;
- fix: changeData with combos problem, closes: #2064;
- fix: improve the position of the context menu before showing up;
- feat: fisheye allows user to config the trigger of scaling range(r) and magnify factor(d) by scaleRBy and scaleDBy respectively;
- feat: add the percent text of magnify factor(d) for fisheye and users are allowed to configure it by show showDPercent.
#### 3.7.1
- fix: hide the tooltip plugin when drag node and contextmenu, closes #1975;
- fix: processParellelEdges without edge id problem;
- fix: label background with left, right position problem, closes #1861;
- fix: create-edge and redo undo problem, #1976;
- fix: tooltip plugin with shouldBegin problem, closes #2006;
- fix: tooltip behavior with shouldBegin problem, closes #2016;
- fix: the position of grid plugins when there is something on the top of the canvas, closes: #2012;
- fix: fisheye destroy and new problem, closes: #2018;
- fix: node event with wrong canvasX and canvasY problem, closes: #2027;
- fix: drag combo and drag node to drop on canvas/combo/node problem;
- feat: improve the performance on the combos;
- fix: redo and undo problem when update item after additem, closes #2019;
- feat: hide shapes beside keyShape while zooming;
- feat: improve the performance on the combos.
#### 3.7.0
- feat: chart node;
- feat: bubble set;
- feat: custom node with JSX;
- feat: minimum spanning tree algorithm;
- feat: path finding algorithm;
- feat: cycle finding algorithm;
- chore: update antv/hierarchy to fix indented tree with dropCap problem.
#### 3.6.2
- feat: find all paths and the shortest path between two nodes;
- feat: fisheye with dragging;
- feat: fisheye with scaling range and d;
- feat: click and drag to create an edge by create-edge behavior;
- feat: process multiple parallel edges to quadratic with proper curveOffset;
- fix: polyline with rect and radius=0 problem;
- fix: arrow state & linkpoint;
- fix: the position of the tooltip plugin;
- fix: drop a node onto a sub node of a combo;
- chore: update hierarchy to solve the children ordering problem for indented tree layout;
- chore: extract the public calculation to enhance the performance of fisheye.
#### 3.6.1-beta
- chore: update g-canvas to support quickHit and pruning the rendering of the graph outside the viewport;
- feat: add statistical chart nodes;
- feat: add hull for create smooth contour to include specific items;
- fix: clear combos before render;
- fix: menu plugin with clickHandler problem.
#### 3.6.1
- feat: image minimap;
- feat: visible can be controlled in the data;
- feat: item type for tooltip plugin;
- feat: menu plugin with shouldUpdate;
- fix: tooltip plugin position and hidden by removeItem;
- fix: tooltip behavior hidden by removeItem;
- fix: menu plugin with clicking on canvas problem;
- fix: menu plugin with clickHandler problem;
- fix: createCombo with double nodes problem.
#### 3.6.0
- feat: fisheye lens plugin;
- feat: lasso-select behavior;
- feat: TimeBar plugin;
- feat: ToolBar plugin.
#### 3.5.12
- fix: node:click is triggered twice while clicking a node;
- fix: update combo edge when drag node out of it problem;
- feat: animate configuration for combo, true by default;
- fix: calling canvas.on('\*', ...) instead of origin way in event controller leads to malposition while dragging nodes with zoomed graph.
#### 3.5.11
- feat: graph.priorityState api;
- feat: graph.on support name:event mode.
- fix: combo edge with uncorrect end points;
- fix: combo polyline edge with wrong path;
- fix: getViewCenter with padding problem;
- fix: cannot read property 'getModel' of null problem on contextmenu when the target is not an item;
- feat: allow user to configure the initial positions for empty combos;
- feat: optimize by hiding edges and shapes which are not keyShape while dragging canvas;
- feat: fix the initial positions by equably distributing for layout to produce similar result.
#### 3.5.10
- fix: fitView and fitCenter with animate in the initial state;
- fix: dulplicated edges in nodeselectchange event of brush-select;
- fix: triple click and drag canvas problem;
- fix: sync the minZoom and maxZoom in drag-canvas and graph;
- fix: integrate getSourceNeighbors and getTargetNeighbors to getNeighbors(node, type);
- feat: initial x and y for combo data;
- feat: dagre layout supports sortByCombo;
- feat: allow user to disable relayout in collapse-expand-combo behavior;
- feat: dijkstra shortest path lenght algorithm.
#### 3.5.9
- fix: multiple animate update shape for combo;
- fix: removeItem from a combo.
#### 3.5.8
- fix: combo edge problem, issues #1722;
- feat: adjacency matrix algorithm;
- feat: Floyd Warshall shortest path algorithm;
- feat: built-in arrows;
- feat: built-in markers;
- fix: force layout with addItem and relayout;
- fix: create combo with parentId problem;
- feat: allow user to configure the pixelRatio for Canvas;
- chore: update G to resolve the blur canvas problem.
#### 3.5.7
- feat: shouldBegin for click-select behavior;
- feat: graph.getGroup, graph.getContainer, graph.getMinZoom, graph.setMinZoom, graph.getMaxZoom, graph.setMaxZoom, graph.getWidth, graph.getHeight API;
- fix: combo edge dashLine attribute;
- fix: combo collapse and expand with edges problem;
- fix: destroy the tooltip DOMs when destroy the graph;
- fix: unify the shape names for custom node and extended node;
- fix: update the edges after first render with collapsed combos.
#### 3.5.6
- feat: dropCap for indented TreeGraph layout.
#### 3.5.5
- fix: custom node with setState problem;
- fix: validationCombo in drag-combo and drag-node.
#### 3.5.3
- feat: focusItem with animation;
- feat: generate the image url of the full graph by graph.toFullDataUrl;
- fix: graph dispears after being dragged out of the canvas and back;
- fix: the graph cannot be dragged back if it is already out of the view;
- fix: size and radius of the linkPoints problem;
- fix: combo graph with unused state name in comboStateStyles;
- fix: preventDefault in drag-canvas behavior.
#### 3.5.2
- feat: degree algorithm;
- feat: graph.getNodeDegree;
- fix: downloadFullImage changes the matrix of the graph problem;
- fix: circular layout modifies the origin data with infinite hierarchy problem.
#### 3.5.1
- feat: graph.fitCenter to align the graph center to canvas center;
- fix: getType is not a function error occurs when addItem with point;
- fix: checking comboTrees avaiability;
- fix: error occurs when createCombo into the graph without any combos;
- fix: endPoint and startPoint are missing in modelConfig type;
- fix: edge background leads to empty canvas when the autoRotate is false;
- fix: combo state style bug.
#### 3.5.0
- feat: combo and combo layout;
- feat: graph algorithms: DFS, BFS and circle detection;
- feat: add `getNeighbors`, `getSourceNeighbors`, `getTargetNeighbors` methods on Graph and Node;
- feat: add `getID` method on Item;
- fix: All Configuration type declarations are migrated to types folder, refer [here](https://github.com/antvis/G6/commit/3691cb51264df8529f75222147ac3f248b71f2f6?diff=unified#diff-76cf0eb5e3d8032945f1ac79ffc5e815R6);
- fix: Some configuration type declarations have removed the `I` prefix, refer [here](https://github.com/antvis/G6/commit/3691cb51264df8529f75222147ac3f248b71f2f6?diff=unified#diff-aa582974831cee2972b8c96cfcce503aR16);
- feat: Util.getLetterWidth and Util.getTextSize.
#### 3.4.10
- fix: TreeGraphData type with style and stateStyles;
- fix: wrong controlpoint position for bezier curves with getControlPoint.
#### 3.4.9
- fix: transplie d3-force to support IE11.
#### 3.4.8
- feat: update the keyShape type minimap when the node or edge's style is updated;
- fix: problem about switching to another applications or browser menu and then switch back, the drag-canvas does not take effect;
- fix: fix the problem about fail to render the graph when the animate and fitView are true by turn off the animate for rendering temporary;
- fix: curveOffset for arc, quadratic, cubic edge.
#### 3.4.7
- feat: downloadFullImage when the (part of) graph is out of the screen;
- feat: With pre-graph has no layout configurations and no positions in data, calling changeData to change into a new data with positions, results in show the node with positions in data;
- feat: allow user to assign curveOffset and curvePostion for Bezier curves;
- fix: moveTo wrong logic problem;
- fix: removeItem to update the minimap.
#### 3.4.6
- same as 3.4.5, published wrongly.
#### 3.4.5
- feat: background of the label on node or edge;
- feat: better performance of minimap;
- fix: minimap viewport displace problem;
- feat: offset of tooltip;
- fix: the length of the node's name affects the tree layout;
- fix: toFront does not work for svg renderer;
- fix: error occurs when the fontSize is smaller than 12 with svg renderer;
- fix: changeData clears states;
- fix: state does not work when default labelCfg is not assigned.
#### 3.4.4
- feat: background color for downloadImage and toDataURL;
- feat: support configure image for grid plugin;
- fix: initial position for fruchterman layout;
- refactor: clip for image node.
- fix: cubic with only one controlPoint error;
- fix: polyline without L attributes.
#### 3.4.3
- fix: support extends BehaviorOption;
- fix: click-select Behavior support multiple selection using ctrl key.
#### 3.4.2
- feat: zoom-canvas behavior supports hiding non-keyshape elements when scaling canvas;
- refactor: when the second parameter is null, clearItemStates will clear all states of the item;
- fix: [changeData bug](https://github.com/antvis/G6/issues/1323);
- fix: update antv/hierarchy to fix fixedRoot for TreeGraph;
- fix: problem of a graph has multiple polyline edges;
- fix: problem of dagre with controlPoints and loop edges.
#### 3.4.1
- feat: force layout clone original data model to allow the customized properties;
- fix: BehaviorOptions type error;
- fix: fitView the graph with data whose nodes and edges are empty arrays;
- fix: rect node positions are changed after calling graph.changeData;
- fix: drag behavior is disabled when the keys are released invalidly;
- refactor: update G and the fill of custom arrow should be assigned by user.
#### 3.4.0
- feat: SVG renderer;
- refactor: new state mechanism with multiple values, sub graphics shape style settings.
#### 3.3.7
- feat: beforeaddchild and afteraddchild emit for TreeGraph;
- feat: built-in nodes' labels can be captured;
- fix: drag shadow caused by localRefresh, update the g-canvas version;
- fix: abnormal polyline bendding;
- fix: collapse-expand trigger problem;
- fix: update nodes with empty string label;
- fix: abnormal rendering when a graph has image nodes and other type nodes.
#### 3.3.6
- feat: support edge weight for dagre layout;
- feat: automatically add draggable to keyShape, users do not need to assign it when custom a node or an edge;
- fix: cannot read 0 or null problem in getPointByCanvas;
- fix: brush-select bug;
- fix: set autoDraw to canvas when graph's setAutoPaint is called;
- fix: modify the usage of bbox in view controller since the interface is chagned by G;
- fix: the shape.attr error in updateShapeStyle;
- fix: local refresh influence on changeData;
- refactor: upgrade g-canvas to 0.3.23 to solve lacking of removeChild function;
- doc: update the demo fo custom behavior doc;
- doc: add plugin demos and cases for site;
- doc: fix shouldUpdate problem in treeWithLargeData demo on the site.
#### 3.3.5
- fix: 3.3.4 is not published successfully;
#### 3.3.4
- fix: 3.3.3 is not published successfully;
- fix: delegate or keyShape type minimap does not display bug;
- fix: dragging bug on minimap with a graph whose bbox is nagtive;
- fix: null matrix bug, create a unit matrix for null.
#### 3.3.3
- fix: delegate or keyShape type minimap does not display bug;
- fix: null matrix in focus() and getLoopCfgs() bug.
#### 3.3.2
- fix: ts type export problem;
- fix: edge with endArrow and autoRotate label bug;
- fix: code prettier;
- fix: line with control points bug;
- fix: matrix null bug.
#### 3.3.1
- fix: resolve 3.3.0 compatibility problem.
#### 3.3.0
- Graph API
- refactor: delete removeEvent function, use off;
- refactor: parameters of Shape animate changed, shape.animate(toAttrs, animateCfg) or shape.animate(onFrame, animateCfg);
- feat: descriptionCfg for modelRect to define the style of description by user;
- feat: update a node from without some shapes to with them, such as linkPoints, label, logo icon and state icon for modelRect;
- feat: the callback paramter of event nodeselectchange is changed to { target, selectedItems, ... };
- feat: support stateStyles in node and edge data;
- feat: calculate pixelRatio by G automatically, user do not need to assign it to graph instance;
- chore: G 4.0
- refactor: refreshLayout of TreeGraph is renamed as layout
- fix: no fan shape in G any more
- feat: recommand to assign name for each shape when addShape
- fix: do not support SVG renderer anymore. no renderer for graph configuration anymore
- refactor: plugins usage is changed into new G6.PluginName()
#### 3.2.7
- feat: supports create the group without nodes in node-group;
- fix: supports destoryed properties and fix issue 1094;
#### 3.2.6
- feat: supports sort the nodes on one circle according to the data ordering or some attribute in radial layout
- fix: grid layout with cols and rows
- feat: fix the nodes with position information in their original data and random the positions of others when the layout is not defined for graph
#### 3.2.5
- fix: click-select trigger error
- fix: solved position problem for minimap
#### 3.2.4
- fix: typescript compile error
- fix: delete sankey lib
#### 3.2.3
- fix: group position error
- fix: supports not set layout type
#### 3.1.5
- feat: supports g6 types file
- fix: set brush-select trigger param to ctrl not work
- fix: when set fitView to true, drag-group Behavior not get desired positon
#### 3.1.3
- feat: radial layout nonoverlap iterations can be controlled by user
- feat: add lock, unlock and hasLocked function, supports lock and unlock node
- fix: mds with discrete points problem
- fix: fruchterman-group layout title position for rect groups
#### 3.1.2
- feat: default behavior supports configuration trigger mode
- feat: node combining supports configuration title
- fix: update demo state styles
#### 3.1.1
- fix: update node use custom config
- fix: update demo
- feat: default node implement getShapeStyle function
#### 3.1.0
- feat: support for rich layoutsrandom, radial, mds, circular, fruchterman, force, dagre
- feat: more flexible configuration for shape
- feat: build-in rich default nodes
- feat: cases that provide layout and default nodes
#### 3.0.7-beta.1
`2019-09-11`
- fix: zoom-canvas support IE and Firefox
#### 3.0.6
`2019-09-11`
- fix: group data util function use module.exports
- feat: update @antv/hierarchy version
#### 3.0.5
`2019-09-10`
- feat: support add and remove group
- feat: support collapse and expand group
- feat: add graph api: collapseGroup and expandGroup
#### 3.0.5-beta.12
- feat: add rect group
- feat: add rect group demo
- feat: add chart node
---
#### 3.0.5-beta.10
- feat: add 5 chart node
- feat: collapse-expand tree support click and dblclick by trigger option
- fix: drag group bug fix
#### 3.0.5-beta.9
- feat: support render group
- feat: support drag group, collapse and expand group, drag node in/out group
- feat: add drag-group、collapse-expand-group and drag-node-with-group behavior
- feat: add drag-group and collapse-expand-group demo
- feat: add register list node demo
#### 3.0.5-beta.8
`2019-07-19`
- feat: add five demos
- refactor: update three behaviors
#### 2.2.5
`2018-12-20`
- feat: add saveimage limitRatio
#### 2.2.4
`2018-12-20`
- fix: bug fix
#### 2.2.3
`2018-12-10`
- fix: bug fix
#### 2.2.2
`2018-11-30`
- fix: tree remove guide will not getEdges.closes #521
#### 2.2.1
`2018-11-25`
- fix: Compatible with MOUSEWHEEL
- fix: fadeIn aniamtion
- fix: fix wheelZoom behaviour by removing the deprecated mousewheel event
#### 2.2.0
`2018-11-22`
- fix: Graph read zIndex
- refactor: Animation
#### 2.1.5
`2018-10-26`
- fix: svg pixelRatio bug
- feat: add wheel event
#### 2.1.4
`2018-10-06`
- fix: custom math.sign to compatible with ie browser.Closes #516.
- fix: legend component from @antv/component
- feat: update svg minimap && fix svg dom event
#### 2.1.3
`2018-09-27`
- feat: add label rotate
- feat: if there is no items the graph box equal canvas size
#### 2.1.2
`2018-09-19`
- fix: dom getShape bug.Closes #472
- fix: template.maxSpanningForest bug
#### 2.1.1
`2018-09-17`
- fix: tool.highlightSubgraph calculate box bug
- fix: plugin.grid.Closes #479
- chore(dev): upgrade babel & torchjs
#### 2.1.0
`2018-09-03`
- feat: svg render
- feat: plugin.layout.forceAtlas2
- feat: plugin.tool.fisheye
- feat: plugin.tool.textDisplay
- feat: plugin.tool.grid
- feat: plugin.template.tableSankey
- feat: plugin.edge.polyline
#### 2.0.5
`2018-07-12`
- improve: add g6 arrow
#### 2.0.4
`2018-07-12`
- feat: layout export group.Closes #355
- feat(plugin): add tool.tooltip. Closes #360.
- style: change the calling way of forceAtlas2 on template.maxSpanningForest
- fix: origin tree data collapsed is true tree edge visible bug.Closes #357
- fix: remove the forceAtlas.js in template.maxSpanningForest, use forceAtlas from layout.forceAtlas2
- fix: add demos: plugin-fisheye, plugin-forceAtlas2, gallery-graphanalyzer
- fix: add demos: plugin-forceAtlas2, plugin-fisheye
#### 2.0.3
`2018-06-29`
- feat: update g to 3.0.x. Closes #346
- fix: group should use rect intersect box. Close #297
- fix(plugin): dagre edge controlpoints remove start point and end point
- style: remove some annotations
- chore: update torchjs && improve demo name
#### 2.0.2
`2018-06-13`
- chore(plugin): require g6 by src/index
- chore(dev test): remove useless test script
- fix(plugin) minimap destroy Closes #308
- fix(saveImage) saveImage bug
- fix(event): fix dom coord. Closes #305
#### 2.0.1
`2018-06-11`
- fix: reDraw edge after layout
- feat: add quadraticCurve config cpd
- feat: add beforelayout && afterlayout event
- chore: .travis.yml add add Node.js
- chore: .travis.yml cache node_modules
#### 2.0.0
`2018-06-06`
- refactor: refactor architecture && code
#### 1.2.1
`2018-03-15`
- feat:    layout interface
#### 1.2.0
`2018-01-15`
- fix: nodeActivedBoxStyle spelling error
- fix: error when deleting a circle
- fix: trigger dragstart while right clicking and moveing
- feat: Unify Layout mechanism
- feat: Plugin mechanism
- feat: Data filter mechanism
- feat: Activated interface
- feat: Action wheelZoomAutoLabel
- feat: configuration of graph -- preciseAnchor
- remove: Global.preciseAnchor
- remove: Layout.Flow、Layout.Force
- improve: html container strategy
#### 1.1.6
`2017-10-15`
- fix: pack problem in layout algorithm
#### 1.1.5
`2017-09-15`
- fix: dragCanvas is effective while mousemove, prevent it from affecting click events
- fix: unactivate pick-up in activeRectBox of node
#### 1.1.4
`2017-08-15`
- feat: graph.invertPoint()
- feat: third configuration of anchor to support style setting, float style, connection
- feat: item.getGroup()
- feat: events -- afteritemrender、itemremove、itemadd
- feat: behaviourSignal
- improve: mouseWheel is affective after focusing the canvas
#### 1.1.3
`2017-08-8`
- feat: Graph configuration -- useNodeSortGroup
- feat: Global.nodeDelegationStyle, Global.edgeDelegationStyle, isolate the delegation of edge and node on graph
- fix: itemremove is triggered before destroying a graph
#### 1.1.2
`2017-08-01`
- feat: dragBlankX dragBlankY
#### 1.1.1
`2017-07-18`
- improve: dragNode protect mechanism
#### 1.1.0
`2017-07-05`
- feat: HTML node
- feat: mapper support callback function
- feat: Graph interfaces -- updateMatrix、changeSize、showAnchor、hideAnchor、updataNodesPosition
- feat:  tool functions -- Util.isNode()、Util.isEdge()
- feat: Shape polyLineFlow
- feat:  dragEdgeEndHideAnchor、dragNodeEndHideAnchor、hoverAnchorSetActived、hoverNodeShowAnchor
#### 1.0.7
`2017-06-21`
- fix: draw one more time in 16ms after first draw
- improve: add zoom by scroll in edit mode
#### 1.0.6
`2017-06-15`
- fix: compatible in chrome in windows. triggering mousemove after first click leads to wrong click event.
- feat: support fix size graphics
- feat: analysis mode
- feat: updateNodesPositon update a set of nodes' position
- improve: change useAnchor to be a configuration of edge
#### 1.0.5
`2017-06-01`
- feat: downloadImage support saving with name
- feat: automatically detect tooltip padding
- improve:  stop the action while mouse dragging out of the canvas
#### 1.0.4
`2017-05-20`
- fix: tree changeData Bug
- fix:  when getAnchorPoints returns auto, anchor is the intersection of edge and the bounding box
- fix: generate node label according to isNull
- feat: viewport parameters -- tl、tc、tr、rc、br、bc、bl、lc、cc
- improve: reduce tolerance to improve the accuracy of interception
- improve: improve tooltip event mechanisom to enhance performance
#### 1.0.3
`2017-05-10`
- feat: graph.guide().link()
#### 1.0.2
`2017-05-10`
- fix: Object.values => Util.getObjectValues
- fix:  when anchorPoints is auto, there is only anchorpoint on edge, it will also return the intersection
- fix: tree update interface Bug
- improve:  represent positions information by group.transfrom()
#### 1.0.1
`2017-04-22`
- fix: copy and paste bug
- feat: draw once in 16ms
- feat: itemactived itemunactived itemhover itemupdate itemmouseenter itemmouseleave
- improve: be clear the status of graphics before activating graphics by frame selection
- improve: dragAddEdge, linkable to anchor
- improve: performance of animation
#### 1.0.0
`2017-03-31`
- feat: fitView configurations
- feat:  graph.zoom()
- feat: wheelZoomHideEdges hide the edges while zooming by wheel
- feat: dragHideEdges hide the edge while dragging edge
- feat: graph.filterBehaviour()
- feat: graph.addBehaviour()
- feat:   graph.changeLayout()
- feat:  read interface, re-define save interface
- feat:  graph.snapshot, graph.downloadImage
- feat:  graph.autoSize()
- feat:  graph.focusPoint()
- feat: tree graph、net graph
- feat: interaction mechanism -- event => action => mode
- feat: animation mechanism
- feat: itemmouseleave、itemmouseenter
- remove: graph.refresh()
- remove: graph.changeNodes()
- remove: graph attributes -- zoomable、dragable、resizeable、selectable
- improve: anchor mechanism
- improve: hide G6.GraphUtil functions, unified in G6.Util
- improve: replace g-canvas-core to g-canvas to improve performance
- improve: Global.nodeAcitveBoxStyle instead of Global.nodeBoxStyle
- improve: afterAdd => afteradd
- improve: G6.Graph to be an abstract class
#### 0.2.3
`2017-03-2`
- fix: dragable for controlling dragable under default mode
- feat: graph.converPoint()
- feat: graph.autoSize()
- feat: rightmousedown leftmousedown wheeldown
- improve: use try catch to prevent the length of getPoint of path equals zero
#### 0.2.2
`2017-02-24`
- fix: add px totooltip css padding
- fix: tooltip mapping error
- fix: accurate intersection
- fix: zoom error on double accuracy screen
- fix: buonding box extended from keyShape
- feat:   afterAdd
- feat: dblclick
- improve: width、height default null
- improve: remove hovershape on node
- improve: tooltip defense mechanism
#### 0.2.1
`2017-02-14`
- fix: rollback when add node
- fix: apply tranformation of parent container while calculating bounding box
- feat: waterPath
- feat: tooltip tip information
- feat: mouseover
- feat: multiSelectable, default false
- feat:  set forceFit to true while width is undefined
- improve: zoomable、dragable、resizeable、selectable default true
#### 0.2.0
`2017-02-07`
- feat: accurate anchor mechanism
- feat: GraphUtil.getEllipsePath
- feat:  GraphUtil.pointsToPolygon
- feat: GraphUtil.pointsToBezier
- feat: GraphUtil.snapPreciseAnchor
- feat: GraphUtil.arrowTo
- feat: GraphUtil.drawEdge
- feat: bezierQuadratic
- feat: node.show
- feat: node.hide
- feat: node.getLinkNodes
- feat:  node.getUnLinkNodes
- feat: node.getRelativeItems
- feat: node.getUnRelativeItems
- feat: edge.show
- feat: edge.hide
- feat: Shape afterDraw
- improve:  the controlling point positions of Bezier Curve 改进贝塞尔曲线控制点位置
- improve: grpah.delete => graph.del
- improve: error when adding id
#### 0.1.4
`2017-01-17`
- fix: delegator of dragging a node is the center of bbox
- fix: use cardinality sort for all the sorting algorithm
- fix: random id on edges
- feat: level sort on edges, edge labels on the top level
- feat:  while extending shape is undefined when register an edge, find the extending shaoe automatically
#### 0.1.3
`2017-01-15`
- fix: judge the existance of the object while operating assistGrid
- feat: rollback judgement, default unactivate
- feat:  style mapping channel
- feat: return the intersections while getAnchorPoints is null or returns false
- feat:  bezierHorizontal、bezierVertical
- improve: 'eventEnd'
#### 0.1.2
`2017-01-12`
- fix: judge the configuration before updating grid
- fix: the size of graphContainer in unsetable, setted by inner canvas
- fix: will not add an edge if the target or source is undefined
- fix: changeSize() maximum tolerance for error
- feat: graph.get('el')  to get canvas DOM
- feat: event exposures shape
#### 0.1.1
`2017-01-09`
- feat: entrance of graph is G6.Graph
#### 0.1.0
`2017-01-07`
- feat: color calculation library
- feat: hot key
- feat: updo, redo
- feat: copy, paste
- feat: reset zoom, auto zoom
- feat: tree graph, linear graph, sankey graph, flow laout
- feat: flow chart package
- feat: timing diagram package
- feat: single selection, frame selection
- feat: node deformation
- feat:  edge deformation
- feat: drag node and edge
- feat: link edge and node
- feat: drag canvas
- feat: zoom
- feat: select mode
- feat: integrate g-graph

View File

@ -0,0 +1,139 @@
# Contribution Guide
If you have any comment or advice, please report your [issue](https://github.com/antvis/g6/issues), or make any change as you wish and submit an [PR](https://github.com/antvis/g6/pulls).
## Reporting New Issues
- Please specify what kind of issue it is.
- Before you report an issue, please search for related issues. Make sure you are not going to open a duplicate issue.
- Explain your purpose clearly in tags(see **Useful Tags**), title, or content.
AntV group members will confirm the purpose of the issue, replace more accurate tags for it, identify related milestone, and assign developers working on it.
## Submitting Code
### Pull Request Guide
If you are developer of AntV repo and you are willing to contribute, feel free to create a new branch, finish your modification and submit a PR. AntV group will review your work and merge it to master branch.
```bash
# Create a new branch for development. The name of branch should be semantic, avoiding words like 'update' or 'tmp'. We suggest to use feature/xxx, if the modification is about to implement a new feature.
$ git checkout -b branch-name
# Run the test after you finish your modification. Add new test cases or change old ones if you feel necessary
$ npm test
# If your modification pass the tests, congradulations it's time to push your work back to us. Notice that the commit message should be wirtten in the following format.
$ git add . # git add -u to delete files
$ git commit -m "fix(role): role.use must xxx"
$ git push origin branch-name
```
Then you can create a Pull Request at [g6](https://github.com/antvis/g6/pulls).
No one can guarantee how much will be remembered about certain PR after some time. To make sure we can easily recap what happened previously, please provide the following information in your PR.
1. Need: What function you want to achieve (Generally, please point out which issue is related).
2. Updating Reason: Different with issue. Briefly describe your reason and logic about why you need to make such modification.
3. Related Testing: Briefly describe what part of testing is relevant to your modification.
4. User Tips: Notice for g6 users. You can skip this part, if the PR is not about update in API or potential compatibility problem.
### Style Guide
Eslint can help to identify styling issues that may exist in your code. Your code is required to pass the test from eslint. Run the test locally by `$ npm run lint`.
### Commit Message Format
You are encouraged to use [angular commit-message-format](https://github.com/angular/angular.js/blob/master/CONTRIBUTING.md#commit-message-format) to write commit message. In this way, we could have a more trackable history and an automatically generated changelog.
```xml
<type>(<scope>): <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>
```
1type
Must be one of the following:
- feat: A new feature
- fix: A bug fix
- docs: Documentation-only changes
- style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
- refactor: A code change that neither fixes a bug nor adds a feature
- perf: A code change that improves performance
- test: Adding missing tests
- chore: Changes to the build process or auxiliary tools and libraries such as documentation generation
- deps: Updates about dependencies
2scope
The scope could be anything specifying place of the commit change.
3subject
Use succinct words to describe what did you do in the commit change.
4body
Feel free to add more content in the body, if you think subject is not self-explanatory enough, such as what it is the purpose or reasons of you commit.
5footer
- **If the commit is a Breaking Change, please note it clearly in this part.**
- related issues, like `Closes #1, Closes #2, #3`
e.g.
```
fix($compile): [BREAKING_CHANGE] couple of unit tests for IE9
Older IEs serialize html uppercased, but IE9 does not...
Would be better to expect case insensitive, unfortunately jasmine does
not allow to user regexps for throw expectations.
Document change on antvis/g6#123
Closes #392
BREAKING CHANGE:
Breaks foo.bar api, foo.baz should be used instead
```
Look at [these files](https://docs.google.com/document/d/1QrDFcIiPjSLDn3EL15IJygNPiHORgU1_OOAqWjiDU5Y/edit) for more details.
## Release
g6 uses semantic versioning in release process based on [semver].
### Branch Strategy
`master` branch is the latest stable version.
- just checkout develop branch from `master`
- All new features will be added into `master` or `next` branch as well as all bug-fix except security issues. In such way, we can motivate developers to update to the latest stable version.
### Release Strategy
In the release of every stable version, there will be a PM who has the following responsibilities in different stages of the release.
#### Preparation
- Set up milestone. Confirm that request is related to milestone.
#### Before Release
- Confirm that performance test is passed and all issues in current Milestone are either closed or can be delayed to later versions.
- Open a new [Release Proposal MR], and write `History` as [node CHANGELOG]. Don't forget to correct content in documentation which is related to the releasing version. Commits can be generated automatically.
```
$ npm run commits
```
- Nominate PM for next stable version.
[semver]: http://semver.org/lang/zh-CN/
[release proposal mr]: https://github.com/nodejs/node/pull/4181
[node changelog]: https://github.com/nodejs/node/blob/master/CHANGELOG.md
[『我是如何发布一个 npm 包的』]: https://fengmk2.com/blog/2016/how-i-publish-a-npm-package

View File

@ -0,0 +1,145 @@
# 代码贡献规范
有任何疑问,欢迎提交 [issue](https://github.com/antvis/g6/issues),或者直接修改提交 [PR](https://github.com/antvis/g6/pulls)!
## 提交 issue
- 请确定 issue 的类型。
- 请避免提交重复的 issue在提交之前搜索现有的 issue。
- 在标签(分类参考**标签分类**), 标题 或者内容中体现明确的意图。
随后 AntV 负责人会确认 issue 意图,更新合适的标签,关联 milestone指派开发者。
## 提交代码
### 提交 Pull Request
如果你有仓库的开发者权限,而且希望贡献代码,那么你可以创建分支修改代码提交 PRAntV 开发团队会 review 代码合并到主干。
```bash
# 先创建开发分支开发,分支名应该有含义,避免使用 update、tmp 之类的
$ git checkout -b branch-name
# 开发完成后跑下测试是否通过,必要时需要新增或修改测试用例
$ npm test
# 测试通过后提交代码message 见下面的规范
$ git add . # git add -u 删除文件
$ git commit -m "fix(role): role.use must xxx"
$ git push origin branch-name
```
提交后就可以在 [g6](https://github.com/antvis/g6/pulls) 创建 Pull Request 了。
由于谁也无法保证过了多久之后还记得多少,为了后期回溯历史的方便,请在提交 MR 时确保提供了以下信息。
1. 需求点(一般关联 issue 或者注释都算)
2. 升级原因(不同于 issue可以简要描述下为什么要处理
3. 框架测试点(可以关联到测试文件,不用详细描述,关键点即可)
4. 关注点(针对用户而言,可以没有,一般是不兼容更新等,需要额外提示)
### 代码风格
你的代码风格必须通过 eslint你可以运行 `$ npm run lint` 本地测试。
### Commit 提交规范
根据 [angular 规范](https://github.com/angular/angular.js/blob/master/CONTRIBUTING.md#commit-message-format)提交 commit这样 history 看起来更加清晰,还可以自动生成 changelog。
```xml
<type>(<scope>): <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>
```
1type
提交 commit 的类型,包括以下几种
- feat: 新功能
- fix: 修复问题
- docs: 修改文档
- style: 修改代码格式,不影响代码逻辑
- refactor: 重构代码,理论上不影响现有功能
- perf: 提升性能
- test: 增加修改测试用例
- chore: 修改工具相关(包括但不限于文档、代码生成等)
- deps: 升级依赖
2scope
修改文件的范围
3subject
用一句话清楚的描述这次提交做了什么
4body
补充 subject适当增加原因、目的等相关因素也可不写。
5footer
- **当有非兼容修改(Breaking Change)时必须在这里描述清楚**
- 关联相关 issue`Closes #1, Closes #2, #3`
示例
```
fix($compile): [BREAKING_CHANGE] couple of unit tests for IE9
Older IEs serialize html uppercased, but IE9 does not...
Would be better to expect case insensitive, unfortunately jasmine does
not allow to user regexps for throw expectations.
Document change on antvis/g6#12
Closes #392
BREAKING CHANGE:
Breaks foo.bar api, foo.baz should be used instead
```
查看具体[文档](https://docs.google.com/document/d/1QrDFcIiPjSLDn3EL15IJygNPiHORgU1_OOAqWjiDU5Y/edit)
## 发布管理
g6 基于 [semver] 语义化版本号进行发布。
`master` 分支为当前稳定发布的版本。
- 直接从 `master` 切出开发分支
- 所有 API 的废弃都需要在当前的稳定版本上 `deprecate` 提示,并保证在当前的稳定版本上一直兼容到新版本的发布。
### 发布策略
每个大版本都有一个发布经理管理PM他/她要做的事情
#### 准备工作:
- 建立 milestone确认需求关联 milestone指派和更新 issues。
#### 发布前:
- 确认当前 Milestone 所有的 issue 都已关闭或可延期,完成性能测试。
- 发起一个新的 [Release Proposal MR],按照 [node CHANGELOG] 进行 `History` 的编写修正文档中与版本相关的内容commits 可以自动生成。
```bash
$ npm run commits
```
- 指定下一个大版本的 PM。
#### 发布时:
- 将老的稳定版本master备份到以当前大版本为名字的分支上例如 `1.x`),并设置 tag 为 {v}.x` v 为当前版本,例如`1.x`)。
- 发布新的稳定版本到 [npm],并通知上层框架进行更新。
- `npm publish` 之前,请先阅读[『我是如何发布一个 npm 包的』]。
[semver]: http://semver.org/lang/zh-CN/
[release proposal mr]: https://github.com/nodejs/node/pull/4181
[node changelog]: https://github.com/nodejs/node/blob/master/CHANGELOG.md
[npm]: http://npmjs.com/
[『我是如何发布一个 npm 包的』]: https://fengmk2.com/blog/2016/how-i-publish-a-npm-package

View File

@ -0,0 +1,156 @@
![](https://user-images.githubusercontent.com/6113694/45008751-ea465300-b036-11e8-8e2a-166cbb338ce2.png)
[![travis-ci](https://img.shields.io/travis/antvis/g6.svg)](https://travis-ci.org/antvis/g6) [![codecov](https://codecov.io/gh/antvis/G6/branch/master/graph/badge.svg)](https://codecov.io/gh/antvis/G6) ![typescript](https://img.shields.io/badge/language-typescript-red.svg) ![MIT](https://img.shields.io/badge/license-MIT-000000.svg) [![npm package](https://img.shields.io/npm/v/@antv/g6.svg)](https://www.npmjs.com/package/@antv/g6) [![NPM downloads](http://img.shields.io/npm/dm/@antv/g6.svg)](https://npmjs.org/package/@antv/g6) [![Percentage of issues still open](http://isitmaintained.com/badge/open/antvis/g6.svg)](http://isitmaintained.com/project/antvis/g6 'Percentage of issues still open')
[English README](README.md)
## 什么是 G6
[G6](https://github.com/antvis/g6) 是一个图可视化引擎。它提供了图的绘制、布局、分析、交互、动画等图可视化的基础能力。旨在让关系变得透明,简单。让用户获得关系数据的 Insight。
<img src='https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*zTjwQaXokeQAAAAAAAAAAABkARQnAQ' width=550 />
基于 G6用户可以快速搭建自己的 **图分析****图编辑** 应用。
<img src='https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*zau8QJcVpDQAAAAAAAAAAABkARQnAQ' height=200 /><img src='https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*RIlETY_S6IoAAAAAAAAAAABkARQnAQ' height=200 />
<img src='https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*cDzXR4jIWr8AAAAAAAAAAABkARQnAQ' height=150 /><img src='https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*DifbSahOblAAAAAAAAAAAABkARQnAQ' height=150 /><img src='https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*HTasSJGC4koAAAAAAAAAAABkARQnAQ' height=150 />
> 强大的动画及交互
<img src="https://user-images.githubusercontent.com/6113694/44995293-02858600-afd5-11e8-840c-349e4730d63d.gif" height=150><img src="https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*I9OdTbXJIi0AAAAAAAAAAABkARQnAQ" height=150><img src="https://user-images.githubusercontent.com/6113694/44995332-2ba61680-afd5-11e8-8cab-db0e9d08ceb7.gif" height=150>
<img src="https://gw.alipayobjects.com/zos/rmsportal/HQxYguinFOMIXrGQOABY.gif" height=150><img src="https://gw.alipayobjects.com/zos/rmsportal/nAugyFgrbrUWPmDIDiQm.gif" height=150><img src="https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*xoufSYcjK2AAAAAAAAAAAABkARQnAQ" height=150>
> 强大的布局
## G6 的特性
G6 作为一款专业的图可视化引擎,具有以下特性:
- 丰富的元素:内置丰富的节点与边元素,自由配置,支持自定义;
- 可控的交互:内置 10+ 交互行为,支持自定义交互;
- 强大的布局:内置了 10+ 常用的图布局,支持自定义布局;
- 便捷的组件:优化内置组件功能及性能;
- 友好的体验:根据用户需求分层梳理文档,支持 TypeScript 类型推断。
除了默认好用、配置自由的内置功能,元素、交互、布局均具有高可扩展的自定义机制。
<img src='https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*Y0c6S7cxjVkAAAAAAAAAAABkARQnAQ' width=800 height=200 />
> 丰富的图元素
## 安装
```bash
$ npm install @antv/g6
```
## 使用
<img src="https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*khbvSrptr0kAAAAAAAAAAABkARQnAQ" width=437 height=148 />
```js
import G6 from '@antv/g6';
const data = {
nodes: [
{
id: 'node1',
label: 'Circle1',
x: 150,
y: 150,
},
{
id: 'node2',
label: 'Circle2',
x: 400,
y: 150,
},
],
edges: [
{
source: 'node1',
target: 'node2',
},
],
};
const graph = new G6.Graph({
container: 'container',
width: 500,
height: 500,
defaultNode: {
type: 'circle',
size: [100],
color: '#5B8FF9',
style: {
fill: '#9EC9FF',
lineWidth: 3,
},
labelCfg: {
style: {
fill: '#fff',
fontSize: 20,
},
},
},
defaultEdge: {
style: {
stroke: '#e2e2e2',
},
},
});
graph.data(data);
graph.render();
```
[![Edit compassionate-lalande-5lxm7](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/compassionate-lalande-5lxm7?fontsize=14&hidenavigation=1&theme=dark)
更多关于 G6 使用的问题,请参考[快速上手](https://antv-g6.gitee.io/zh/docs/manual/getting-started)。
## 开发
```bash
$ npm install
# run test case
$ npm test
# run test case in watch mode
npm test -- --watch ./tests/unit/algorithm/find-path-spec
DEBUG_MODE=1 npm test -- --watch ./tests/unit/algorithm/find-path-spec
# build watching file changes and run demos
$ npm run demos
```
## 文档
- <a href='https://g6.antv.vision/zh/docs/manual/tutorial/preface' target='_blank'>入门教程</a>
- <a href='https://g6.antv.vision/zh/docs/manual/middle/overview' target='_blank'>核心概念</a>
- <a href='https://g6.antv.vision/zh/docs/manual/advanced/coordinate-system' target='_blank'>扩展阅读</a>
- <a href='https://g6.antv.vision/zh/docs/api/Graph' target='_blank'>API</a>
## G6 图可视化交流群
欢迎各界 G6 使用者、图可视化爱好者加入 **G6 图可视化交流群****G6 图可视化交流二群**(钉钉群,使用钉钉扫一扫加入)讨论与交流。
> **G6 图可视化交流群** 已满员,该群会不定期移除不活跃的成员。
> 由于值班同学的时间和精力有限,**G6 图可视化交流二群** 中的问题我们会不定期回复。欢迎对 G6 感兴趣的同学加入到答疑中来,非常感谢!交流群-1 已满,请加交流群-2。
<img src='https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*LFppR6_BfdYAAAAAAAAAAABkARQnAQ' width=400 alt="">
<img src='https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*hzfaSrAj0jkAAAAAAAAAAABkARQnAQ' width=400 alt="">
## 如何贡献
请让我们知道您要解决或贡献什么,所以在贡献之前请先提交 [issues](https://github.com/antvis/g6/issues) 描述 bug 或建议。
成为一个贡献者前请阅读 [代码贡献规范](https://github.com/antvis/g6/blob/master/CONTRIBUTING.zh-CN.md).
## License
[MIT license](./LICENSE).

152
packages/core/README.md Normal file
View File

@ -0,0 +1,152 @@
# G6: A Graph Visualization Framework in TypeScript.
![](https://user-images.githubusercontent.com/6113694/45008751-ea465300-b036-11e8-8e2a-166cbb338ce2.png)
[![travis-ci](https://img.shields.io/travis/antvis/g6/master.svg)](https://travis-ci.org/antvis/g6) [![codecov](https://codecov.io/gh/antvis/G6/branch/master/graph/badge.svg)](https://codecov.io/gh/antvis/G6) ![typescript](https://img.shields.io/badge/language-typescript-red.svg) ![MIT](https://img.shields.io/badge/license-MIT-000000.svg) [![npm package](https://img.shields.io/npm/v/@antv/g6.svg)](https://www.npmjs.com/package/@antv/g6) [![NPM downloads](http://img.shields.io/npm/dm/@antv/g6.svg)](https://npmjs.org/package/@antv/g6) [![Percentage of issues still open](http://isitmaintained.com/badge/open/antvis/g6.svg)](http://isitmaintained.com/project/antvis/g6 'Percentage of issues still open')
[中文 README](README-zh_CN.md)
## What is G6
[G6](https://github.com/antvis/g6) is a graph visualization engine, which provides a set of basic mechanisms, including rendering, layout, analysis, interaction, animation, and other auxiliary tools. G6 aims to simplify the relationships, and help people to obtain the insight of relational data.
<img src='https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*zTjwQaXokeQAAAAAAAAAAABkARQnAQ' width=550 />
Developers are able to build graph visualization **analysis** applications or graph visualization **modeling** applications easily.
<img src='https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*zau8QJcVpDQAAAAAAAAAAABkARQnAQ' height=200 /><img src='https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*RIlETY_S6IoAAAAAAAAAAABkARQnAQ' height=200 />
<img src='https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*cDzXR4jIWr8AAAAAAAAAAABkARQnAQ' height=150 /><img src='https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*DifbSahOblAAAAAAAAAAAABkARQnAQ' height=150 /><img src='https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*HTasSJGC4koAAAAAAAAAAABkARQnAQ' height=150 />
> Powerful Animation and Interactions
<img src="https://user-images.githubusercontent.com/6113694/44995293-02858600-afd5-11e8-840c-349e4730d63d.gif" height=150><img src="https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*I9OdTbXJIi0AAAAAAAAAAABkARQnAQ" height=150><img src="https://user-images.githubusercontent.com/6113694/44995332-2ba61680-afd5-11e8-8cab-db0e9d08ceb7.gif" height=150>
<img src="https://gw.alipayobjects.com/zos/rmsportal/HQxYguinFOMIXrGQOABY.gif" height=150><img src="https://gw.alipayobjects.com/zos/rmsportal/nAugyFgrbrUWPmDIDiQm.gif" height=150><img src="https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*xoufSYcjK2AAAAAAAAAAAABkARQnAQ" height=150>
> Powerful Layouts
## Features
- Abundant Built-in Items: Nodes and edges with free configurations;
- Steerable Interactions: More than 10 basic interaction behaviors ;
- Powerful Layout: More than 10 layout algorithms;
- Convenient Components: Outstanding ability and performance;
- Friendly User Experience: Complete documents for different levels of user requirements. TypeScript supported.
G6 concentrates on the principle of 'good by default'. In addition, the custom mechanism of the item, interation behavior, and layout satisfies the customazation requirements.
<img src='https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*Y0c6S7cxjVkAAAAAAAAAAABkARQnAQ' width=800 height=200 />
> Abundant Built-in Items
## Installation
```bash
$ npm install @antv/g6
```
## Usage
<img src="https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*khbvSrptr0kAAAAAAAAAAABkARQnAQ" width=437 height=148 />
```js
import G6 from '@antv/g6';
const data = {
nodes: [
{
id: 'node1',
label: 'Circle1',
x: 150,
y: 150,
},
{
id: 'node2',
label: 'Circle2',
x: 400,
y: 150,
},
],
edges: [
{
source: 'node1',
target: 'node2',
},
],
};
const graph = new G6.Graph({
container: 'container',
width: 500,
height: 500,
defaultNode: {
type: 'circle',
size: [100],
color: '#5B8FF9',
style: {
fill: '#9EC9FF',
lineWidth: 3,
},
labelCfg: {
style: {
fill: '#fff',
fontSize: 20,
},
},
},
defaultEdge: {
style: {
stroke: '#e2e2e2',
},
},
});
graph.data(data);
graph.render();
```
[![Edit compassionate-lalande-5lxm7](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/compassionate-lalande-5lxm7?fontsize=14&hidenavigation=1&theme=dark)
For more information of the usage, please refer to [Getting Started](https://antv-g6.gitee.io/en/docs/manual/getting-started).
## Development
```bash
$ npm install
# run test case
$ npm test
# run test case in watch mode
npm test -- --watch ./tests/unit/algorithm/find-path-spec
DEBUG_MODE=1 npm test -- --watch ./tests/unit/algorithm/find-path-spec
# build watching file changes and run demos
$ npm run demos
```
## Documents
- <a href='https://g6.antv.vision/en/docs/manual/tutorial/preface' target='_blank'>Tutorial</a>
- <a href='https://g6.antv.vision/en/docs/manual/middle/overview' target='_blank'>Middle Guides</a>
- <a href='https://g6.antv.vision/en/docs/manual/advanced/coordinate-system' target='_blank'>Further Reading</a>
- <a href='https://g6.antv.vision/en/docs/api/Graph' target='_blank'>API Reference</a>
## G6 Communication Group
Users are welcome to join the **G6 Communication Group** or **G6 Communication Group-2** (They are DingTalk groups). We also welcome the github issues. The Group is full, join Group-2 instead.
<img src='https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*LFppR6_BfdYAAAAAAAAAAABkARQnAQ' width=400 alt="">
<img src='https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*hzfaSrAj0jkAAAAAAAAAAABkARQnAQ' width=400 alt="">
## How to Contribute
Please let us know what you are you going to help. Do check out [issues](https://github.com/antvis/g6/issues) for bug reports or suggestions first.
To become a contributor, please follow our [contributing guide](https://github.com/antvis/g6/blob/master/CONTRIBUTING.md).
## License
[MIT license](./LICENSE).

View File

@ -0,0 +1,19 @@
module.exports = {
runner: 'jest-electron/runner',
testEnvironment: 'jest-electron/environment',
preset: 'ts-jest',
collectCoverage: false,
collectCoverageFrom: ['src/**/*.{ts,js}', '!**/node_modules/**', '!**/vendor/**'],
testRegex: '/tests/.*-spec\\.ts?$',
moduleDirectories: ['node_modules', 'src'],
moduleFileExtensions: ['js', 'ts', 'json'],
moduleNameMapper: {
'@g6/types': '<rootDir>/types',
'@g6/(.*)': '<rootDir>/src/$1',
},
globals: {
'ts-jest': {
diagnostics: false,
},
},
};

109
packages/core/package.json Normal file
View File

@ -0,0 +1,109 @@
{
"name": "@antv/g6-core",
"version": "4.0.3",
"description": "A Graph Visualization Framework in JavaScript",
"keywords": [
"antv",
"g6",
"graph",
"graph analysis",
"graph editor",
"graph visualization",
"relational data"
],
"homepage": "https://g6.antv.vision",
"bugs": {
"url": "https://github.com/antvis/g6/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/antvis/g6"
},
"license": "MIT",
"author": "https://github.com/orgs/antvis/people",
"files": [
"package.json",
"es",
"lib",
"dist",
"LICENSE",
"README.md"
],
"main": "lib/index.js",
"module": "es/index.js",
"browser": "dist/g6.min.js",
"types": "lib/index.d.ts",
"scripts": {
"build": "npm run clean && father build && npm run build:umd",
"build:umd": "webpack --config webpack.config.js --mode production --profile --json > stats.json",
"ci": "npm run build && npm run coverage",
"clean": "rimraf es esm lib dist",
"coverage": "jest --coverage",
"doc": "rimraf apis && typedoc",
"lint": "eslint --ext .js,.jsx,.ts,.tsx --format=pretty \"./\"",
"lint:src": "eslint --ext .ts --format=pretty \"./src\"",
"prettier": "prettier -c --write \"**/*\"",
"test": "jest",
"test-live": "DEBUG_MODE=1 jest --watch ./tests/unit/graph/graph-watermarker-spec.ts",
"lint-staged:js": "eslint --ext .js,.jsx,.ts,.tsx"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"**/*.{js,jsx,ts,tsx}": [
"npm run lint-staged:js",
"prettier --write"
],
"**/*.{less,md,json}": [
"prettier --write"
]
},
"dependencies": {
"@antv/dom-util": "^2.0.1",
"@antv/event-emitter": "~0.1.0",
"@antv/g-base": "^0.5.1",
"@antv/g-canvas": "^0.5.2",
"@antv/g-math": "^0.1.1",
"@antv/g-svg": "^0.5.1",
"@antv/g-webgpu": "^0.5.1",
"@antv/hierarchy": "^0.6.2",
"@antv/matrix-util": "^2.0.4",
"@antv/path-util": "^2.0.3",
"@antv/scale": "^0.3.1",
"@antv/util": "~2.0.5",
"dagre": "^0.8.5",
"insert-css": "^2.0.0",
"ml-matrix": "^6.5.0"
},
"devDependencies": {
"@babel/core": "^7.7.7",
"@babel/plugin-proposal-class-properties": "^7.1.0",
"@babel/preset-react": "^7.7.4",
"@turf/turf": "^5.1.6",
"@types/jest": "^25.2.1",
"@types/node": "13.11.1",
"@typescript-eslint/eslint-plugin": "^3.9.0",
"@umijs/fabric": "^2.0.0",
"awesome-typescript-loader": "^5.2.1",
"babel-loader": "^8.0.6",
"eslint": "^7.7.0",
"event-simulate": "~1.0.0",
"father": "^2.29.1",
"husky": "^4.2.5",
"jest": "^26.0.1",
"jest-electron": "^0.1.7",
"jest-extended": "^0.11.2",
"lint-staged": "^10.2.11",
"pre-commit": "^1.2.2",
"prettier": "^2.0.5",
"rimraf": "^3.0.0",
"ts-jest": "^24.1.0",
"ts-loader": "^7.0.3",
"typescript": "^3.5.3",
"webpack": "^4.41.4",
"webpack-cli": "^3.3.10"
}
}

View File

@ -0,0 +1,39 @@
import { IGraph } from '../interface/graph';
import { Matrix } from '../types';
const adjMatrix = (graph: IGraph, directed?: boolean) => {
const nodes = graph.getNodes();
const edges = graph.getEdges();
const matrix: Matrix[] = [];
// map node with index in data.nodes
const nodeMap: {
[key: string]: number;
} = {};
if (!nodes) {
throw new Error('invalid nodes data!');
}
if (nodes) {
nodes.forEach((node, i) => {
nodeMap[node.getID()] = i;
const row: number[] = [];
matrix.push(row);
});
}
if (edges) {
edges.forEach((e) => {
const model = e.getModel();
const { source, target } = model;
const sIndex = nodeMap[source as string];
const tIndex = nodeMap[target as string];
matrix[sIndex][tIndex] = 1;
if (!directed) {
matrix[tIndex][sIndex] = 1;
}
});
}
return matrix;
};
export default adjMatrix;

View File

@ -0,0 +1,83 @@
export default class Comparator {
private compare;
/**
* @param {function(a: *, b: *)} [compareFunction] - It may be custom compare function that, let's
* say may compare custom objects together.
*/
constructor(compareFunction) {
this.compare = compareFunction || Comparator.defaultCompareFunction;
}
/**
* Default comparison function. It just assumes that "a" and "b" are strings or numbers.
* @param {(string|number)} a
* @param {(string|number)} b
* @returns {number}
*/
static defaultCompareFunction(a, b) {
if (a === b) {
return 0;
}
return a < b ? -1 : 1;
}
/**
* Checks if two variables are equal.
* @param {*} a
* @param {*} b
* @return {boolean}
*/
equal(a, b) {
return this.compare(a, b) === 0;
}
/**
* Checks if variable "a" is less than "b".
* @param {*} a
* @param {*} b
* @return {boolean}
*/
lessThan(a, b) {
return this.compare(a, b) < 0;
}
/**
* Checks if variable "a" is greater than "b".
* @param {*} a
* @param {*} b
* @return {boolean}
*/
greaterThan(a, b) {
return this.compare(a, b) > 0;
}
/**
* Checks if variable "a" is less than or equal to "b".
* @param {*} a
* @param {*} b
* @return {boolean}
*/
lessThanOrEqual(a, b) {
return this.lessThan(a, b) || this.equal(a, b);
}
/**
* Checks if variable "a" is greater than or equal to "b".
* @param {*} a
* @param {*} b
* @return {boolean}
*/
greaterThanOrEqual(a, b) {
return this.greaterThan(a, b) || this.equal(a, b);
}
/**
* Reverses the comparison order.
*/
reverse() {
const compareOriginal = this.compare;
this.compare = (a, b) => compareOriginal(b, a);
}
}

View File

@ -0,0 +1,24 @@
import { IGraph } from '../interface/graph';
const degree = (graph: IGraph) => {
const degrees = {};
graph.getNodes().forEach((node) => {
degrees[node.getID()] = {
degree: 0,
inDegree: 0,
outDegree: 0,
};
});
graph.getEdges().forEach((edge) => {
degrees[edge.getSource().getID()].degree++;
degrees[edge.getSource().getID()].outDegree++;
degrees[edge.getTarget().getID()].degree++;
degrees[edge.getTarget().getID()].inDegree++;
});
return degrees;
};
export default degree;

View File

@ -0,0 +1,38 @@
import { IGraph } from '../interface/graph';
import adjMatrix from './adjacent-matrix';
import { Matrix } from '../types';
const floydWarshall = (graph: IGraph, directed?: boolean) => {
let adjacentMatrix = graph.get('adjMatrix');
if (!adjacentMatrix) {
adjacentMatrix = adjMatrix(graph, directed);
}
const dist: Matrix[] = [];
const size = adjacentMatrix.length;
for (let i = 0; i < size; i += 1) {
dist[i] = [];
for (let j = 0; j < size; j += 1) {
if (i === j) {
dist[i][j] = 0;
} else if (adjacentMatrix[i][j] === 0 || !adjacentMatrix[i][j]) {
dist[i][j] = Infinity;
} else {
dist[i][j] = adjacentMatrix[i][j];
}
}
}
// floyd
for (let k = 0; k < size; k += 1) {
for (let i = 0; i < size; i += 1) {
for (let j = 0; j < size; j += 1) {
if (dist[i][j] > dist[i][k] + dist[k][j]) {
dist[i][j] = dist[i][k] + dist[k][j];
}
}
}
}
return dist;
};
export default floydWarshall;

View File

@ -0,0 +1,3 @@
export { default as degree } from './degree';
export { default as adjMatrix } from './adjacent-matrix';
export { default as floydWarshall } from './floydWarshall';

View File

@ -0,0 +1,239 @@
import Comparator from '../../comparator';
/**
*
*/
export class LinkedListNode {
public value;
public next: LinkedListNode;
constructor(value, next: LinkedListNode = null) {
this.value = value;
this.next = next;
}
toString(callback) {
return callback ? callback(this.value) : `${this.value}`;
}
}
export default class LinkedList {
public head: LinkedListNode;
public tail: LinkedListNode;
public compare: Comparator;
constructor(comparatorFunction = null) {
this.head = null;
this.tail = null;
this.compare = new Comparator(comparatorFunction);
}
/**
*
* @param value
*/
prepend(value) {
// 在头部添加一个节点
const newNode = new LinkedListNode(value, this.head);
this.head = newNode;
if (!this.tail) {
this.tail = newNode;
}
return this;
}
/**
*
* @param value
*/
append(value) {
const newNode = new LinkedListNode(value);
// 如果不存在头节点,则将创建的新节点作为头节点
if (!this.head) {
this.head = newNode;
this.tail = newNode;
return this;
}
// 将新节点附加到链表末尾
this.tail.next = newNode;
this.tail = newNode;
return this;
}
/**
*
* @param value
*/
delete(value): LinkedListNode {
if (!this.head) {
return null;
}
let deleteNode = null;
// 如果删除的是头部元素则将next作为头元素
while (this.head && this.compare.equal(this.head.value, value)) {
deleteNode = this.head;
this.head = this.head.next;
}
let currentNode = this.head;
if (currentNode !== null) {
// 如果删除了节点以后将next节点前移
while (currentNode.next) {
if (this.compare.equal(currentNode.next.value, value)) {
deleteNode = currentNode.next;
currentNode.next = currentNode.next.next;
} else {
currentNode = currentNode.next;
}
}
}
// 检查尾部节点是否被删除
if (this.compare.equal(this.tail.value, value)) {
this.tail = currentNode;
}
return deleteNode;
}
/**
*
* @param param0
*/
find({ value = undefined, callback = undefined }): LinkedListNode {
if (!this.head) {
return null;
}
let currentNode = this.head;
while (currentNode) {
// 如果指定了 callback则按指定的 callback 查找
if (callback && callback(currentNode.value)) {
return currentNode;
}
// 如果指定了 value则按 value 查找
if (value !== undefined && this.compare.equal(currentNode.value, value)) {
return currentNode;
}
currentNode = currentNode.next;
}
return null;
}
/**
*
*/
deleteTail() {
const deletedTail = this.tail;
if (this.head === this.tail) {
// 链表中只有一个元素
this.head = null;
this.tail = null;
return deletedTail;
}
let currentNode = this.head;
while (currentNode.next) {
if (!currentNode.next.next) {
currentNode.next = null;
} else {
currentNode = currentNode.next;
}
}
this.tail = currentNode;
return deletedTail;
}
/**
*
*/
deleteHead() {
if (!this.head) {
return null;
}
const deletedHead = this.head;
if (this.head.next) {
this.head = this.head.next;
} else {
this.head = null;
this.tail = null;
}
return deletedHead;
}
/**
*
* @param values
*/
fromArray(values) {
values.forEach((value) => this.append(value));
return this;
}
/**
*
*/
toArray() {
const nodes = [];
let currentNode = this.head;
while (currentNode) {
nodes.push(currentNode);
currentNode = currentNode.next;
}
return nodes;
}
/**
*
*/
reverse() {
let currentNode = this.head;
let prevNode = null;
let nextNode = null;
while (currentNode) {
// 存储下一个元素节点
nextNode = currentNode.next;
// 更改当前节点的下一个节点,以便将它连接到上一个节点上
currentNode.next = prevNode;
// 将 prevNode 和 currentNode 向前移动一步
prevNode = currentNode;
currentNode = nextNode;
}
this.tail = this.head;
this.head = prevNode;
}
toString(callback = undefined) {
return this.toArray()
.map((node) => node.toString(callback))
.toString();
}
}

View File

@ -0,0 +1,65 @@
import LinkedList from '../linked-list';
export default class Stack {
private linkedList: LinkedList;
private maxStep: number;
constructor(maxStep: number = 10) {
this.linkedList = new LinkedList();
this.maxStep = maxStep;
}
get length() {
return this.linkedList.toArray().length;
}
/**
*
*/
isEmpty() {
return !this.linkedList.head;
}
/**
*
*/
isMaxStack() {
return this.toArray().length >= this.maxStep;
}
/**
* 访
*/
peek() {
if (this.isEmpty()) {
return null;
}
// 返回头部元素,不删除元素
return this.linkedList.head.value;
}
push(value) {
this.linkedList.prepend(value);
if (this.length > this.maxStep) {
this.linkedList.deleteTail();
}
}
pop() {
const removeHead = this.linkedList.deleteHead();
return removeHead ? removeHead.value : null;
}
toArray() {
return this.linkedList.toArray().map((node) => node.value);
}
clear() {
while (!this.isEmpty()) {
this.pop();
}
}
}

View File

@ -0,0 +1,51 @@
import { clone, each, wrapBehavior } from '@antv/util';
import { BehaviorOption } from '../types';
import behaviorOption from './behaviorOption';
export default class Behavior {
// 所有自定义的 Behavior 的实例
private static types = {};
/**
* Behavior
* @param type Behavior
* @param behavior Behavior
*/
public static registerBehavior(type: string, behavior: BehaviorOption) {
if (!behavior) {
throw new Error(`please specify handler for this behavior: ${type}`);
}
const prototype = clone(behaviorOption);
Object.assign(prototype, behavior);
// eslint-disable-next-line func-names
const base = function (cfg: object) {
Object.assign(this, this.getDefaultCfg(), cfg);
const events = this.getEvents();
this.events = null;
const eventsToBind = {};
if (events) {
each(events, (handle, event) => {
eventsToBind[event] = wrapBehavior(this, handle);
});
this.events = eventsToBind;
}
};
base.prototype = prototype;
Behavior.types[type] = base;
}
public static hasBehavior(type: string) {
return !!Behavior.types[type];
}
public static getBehavior(type: string) {
return Behavior.types[type];
}
}

View File

@ -0,0 +1,79 @@
import { each } from '@antv/util';
import { IGraph } from '../interface/graph';
import { G6Event } from '../types';
// 自定义 Behavior 时候共有的方法
export default {
getDefaultCfg() {
return {};
},
/**
* register event handler, behavior will auto bind events
* for example:
* return {
* click: 'onClick'
* }
*/
getEvents() {
return {};
},
shouldBegin() {
return true;
},
shouldUpdate() {
return true;
},
shouldEnd() {
return true;
},
/**
* auto bind events when register behavior
* @param graph Graph instance
*/
bind(graph: IGraph) {
const { events } = this;
this.graph = graph;
if (
this.type === 'drag-canvas' ||
this.type === 'brush-select' ||
this.type === 'lasso-select'
) {
graph.get('canvas').set('draggable', true);
}
each(events, (handler: () => void, event: G6Event) => {
graph.on(event, handler);
});
// To avoid the tabs switching makes the keydown related behaviors disable
document.addEventListener('visibilitychange', () => {
this.keydown = false;
});
},
unbind(graph: IGraph) {
const { events } = this;
if (
this.type === 'drag-canvas' ||
this.type === 'brush-select' ||
this.type === 'lasso-select'
) {
graph.get('canvas').set('draggable', false);
}
each(events, (handler: () => void, event: G6Event) => {
graph.off(event, handler);
});
},
get(val: string) {
return (this as any)[val];
},
set(key: string, val: any) {
(this as any)[key] = val;
return this;
},
};

View File

@ -0,0 +1,3 @@
import Behavior from "./behavior";
export default Behavior;

288
packages/core/src/global.ts Normal file
View File

@ -0,0 +1,288 @@
const subjectColor = 'rgb(95, 149, 255)';
const backColor = 'rgb(255, 255, 255)';
const textColor = 'rgb(0, 0, 0)';
// const colorSet = {
// // for nodes
// mainStroke: subjectColor,
// mainFill: subjectColor01,
// activeStroke: subjectColor,
// activeFill: subjectColor005,
// inactiveStroke: subjectColor04,
// inactiveFill: subjectColor005,
// selectedStroke: subjectColor,
// selectedFill: backColor,
// highlightStroke: deeperSubject,
// highlightFill: subjectColor02,
// disableStroke: disableColor03,
// disableFill: disableColor005,
// // for edges
// edgeMainStroke: disableColor03,
// edgeActiveStroke: subjectColor,
// edgeInactiveStroke: disableColor02,
// edgeSelectedStroke: subjectColor,
// edgeHighlightStroke: subjectColor,
// edgeDisableStroke: disableColor01,
// // for combos
// comboMainStroke: disableColor03,
// comboMainFill: disableColor002,
// comboActiveStroke: subjectColor,
// comboActiveFill: subjectColor005,
// comboInactiveStroke: disableColor03,
// comboInactiveFill: disableColor002,
// comboSelectedStroke: subjectColor,
// comboSelectedFill: disableColor002,
// comboHighlightStroke: deeperSubject, // 'rgb(53, 119, 222)', // TODO: how to generate it ???
// comboHighlightFill: disableColor002,
// comboDisableStroke: disableColor02,
// comboDisableFill: disableColor005,
// }
const colorSet = {
// for nodes
mainStroke: subjectColor,
mainFill: backColor,
activeStroke: subjectColor,
activeFill: backColor,
inactiveStroke: backColor,
inactiveFill: backColor,
selectedStroke: subjectColor,
selectedFill: backColor,
highlightStroke: backColor,
highlightFill: backColor,
disableStroke: backColor,
disableFill: backColor,
// for edges
edgeMainStroke: backColor,
edgeActiveStroke: subjectColor,
edgeInactiveStroke: backColor,
edgeSelectedStroke: subjectColor,
edgeHighlightStroke: subjectColor,
edgeDisableStroke: backColor,
// for combos
comboMainStroke: backColor,
comboMainFill: backColor,
comboActiveStroke: subjectColor,
comboActiveFill: backColor,
comboInactiveStroke: backColor,
comboInactiveFill: backColor,
comboSelectedStroke: subjectColor,
comboSelectedFill: backColor,
comboHighlightStroke: backColor, // 'rgb(53, 119, 222)', // TODO: how to generate it ???
comboHighlightFill: backColor,
comboDisableStroke: backColor,
comboDisableFill: backColor,
};
export default {
version: '4.0.3',
rootContainerClassName: 'root-container',
nodeContainerClassName: 'node-container',
edgeContainerClassName: 'edge-container',
comboContainerClassName: 'combo-container',
delegateContainerClassName: 'delegate-container',
defaultLoopPosition: 'top',
nodeLabel: {
style: {
fill: '#000',
fontSize: 12,
textAlign: 'center',
textBaseline: 'middle',
},
offset: 4, // 节点的默认文本不居中时的偏移量
},
defaultNode: {
type: 'circle',
style: {
lineWidth: 1,
stroke: colorSet.mainStroke,
fill: colorSet.mainFill,
},
size: 20,
color: colorSet.mainStroke,
linkPoints: {
size: 8,
lineWidth: 1,
fill: colorSet.activeFill,
stroke: colorSet.activeStroke,
},
},
// 节点应用状态后的样式,默认仅提供 active、selected、highlight、inactive、disable用户可以自己扩展
nodeStateStyles: {
active: {
fill: colorSet.activeFill,
stroke: colorSet.activeStroke,
lineWidth: 2,
shadowColor: colorSet.mainStroke,
shadowBlur: 10,
},
selected: {
fill: colorSet.selectedFill,
stroke: colorSet.selectedStroke,
lineWidth: 4,
shadowColor: colorSet.selectedStroke,
shadowBlur: 10,
'text-shape': {
fontWeight: 500,
},
},
highlight: {
fill: colorSet.highlightFill,
stroke: colorSet.highlightStroke,
lineWidth: 2,
'text-shape': {
fontWeight: 500,
},
},
inactive: {
fill: colorSet.inactiveFill,
stroke: colorSet.inactiveStroke,
lineWidth: 1,
},
disable: {
fill: colorSet.disableFill,
stroke: colorSet.disableStroke,
lineWidth: 1,
},
},
edgeLabel: {
style: {
fill: textColor,
textAlign: 'center',
textBaseline: 'middle',
fontSize: 12,
},
},
defaultEdge: {
type: 'line',
size: 1,
style: {
stroke: colorSet.edgeMainStroke,
lineAppendWidth: 2,
},
color: colorSet.edgeMainStroke,
},
// 边应用状态后的样式,默认仅提供 active、selected、highlight、inactive、disable用户可以自己扩展
edgeStateStyles: {
active: {
stroke: colorSet.edgeActiveStroke,
lineWidth: 1,
},
selected: {
stroke: colorSet.edgeSelectedStroke,
lineWidth: 2,
shadowColor: colorSet.edgeSelectedStroke,
shadowBlur: 10,
'text-shape': {
fontWeight: 500,
},
},
highlight: {
stroke: colorSet.edgeHighlightStroke,
lineWidth: 2,
'text-shape': {
fontWeight: 500,
},
},
inactive: {
stroke: colorSet.edgeInactiveStroke,
lineWidth: 1,
},
disable: {
stroke: colorSet.edgeDisableStroke,
lineWidth: 1,
},
},
comboLabel: {
style: {
fill: textColor,
// textAlign: 'center',
textBaseline: 'middle',
fontSize: 12,
},
refY: 10, // Combo 的默认文本不居中时的偏移量
refX: 10, // Combo 的默认文本不居中时的偏移量
},
defaultCombo: {
type: 'circle',
style: {
fill: colorSet.comboMainFill,
lineWidth: 1,
stroke: colorSet.comboMainStroke,
r: 5,
width: 20,
height: 10,
},
size: [20, 5],
color: colorSet.comboMainStroke,
padding: [25, 20, 15, 20],
},
// combo 应用状态后的样式,默认仅提供 active、selected、highlight、inactive、disable用户可以自己扩展
comboStateStyles: {
active: {
stroke: colorSet.comboActiveStroke,
lineWidth: 1,
fill: colorSet.comboActiveFill,
},
selected: {
stroke: colorSet.comboSelectedStroke,
lineWidth: 2,
fill: colorSet.comboSelectedFill,
shadowColor: colorSet.comboSelectedStroke,
shadowBlur: 10,
'text-shape': {
fontWeight: 500,
},
},
highlight: {
stroke: colorSet.comboHighlightStroke,
lineWidth: 2,
fill: colorSet.comboHighlightFill,
'text-shape': {
fontWeight: 500,
},
},
inactive: {
stroke: colorSet.comboInactiveStroke,
fill: colorSet.comboInactiveFill,
lineWidth: 1,
},
disable: {
stroke: colorSet.comboDisableStroke,
fill: colorSet.comboDisableFill,
lineWidth: 1,
},
},
delegateStyle: {
fill: '#F3F9FF',
fillOpacity: 0.5,
stroke: '#1890FF',
strokeOpacity: 0.9,
lineDash: [5, 5],
},
};

View File

@ -0,0 +1,264 @@
import { addEventListener } from '@antv/dom-util';
import Canvas from '@antv/g-base/lib/abstract/canvas';
import { Group, Shape } from '@antv/g-canvas';
import { each, isNil, wrapBehavior } from '@antv/util';
import Graph from '../graph';
import { IG6GraphEvent, Matrix, Item } from '../../types';
import { cloneEvent, isViewportChanged } from '../../util/base';
type ShapeBase = Shape.Base;
type Fun = () => void;
// const EVENTS = [
// 'click',
// 'mousedown',
// 'mouseup',
// 'dblclick',
// 'contextmenu',
// 'mouseenter',
// 'mouseout',
// 'mouseover',
// 'mousemove',
// 'mouseleave',
// 'dragstart',
// 'dragend',
// 'drag',
// 'dragenter',
// 'dragleave',
// 'dragover',
// 'dragout', // no this event
// 'drop',
// 'touchstart',
// 'touchmove',
// 'touchend',
// ];
export default class EventController {
private graph: Graph;
private extendEvents: any[];
private canvasHandler!: Fun;
private dragging: boolean;
private preItem: Item | null = null;
public destroyed: boolean;
constructor(graph: Graph) {
this.graph = graph;
this.extendEvents = [];
this.dragging = false;
this.destroyed = false;
this.initEvents();
}
// 初始化 G6 中的事件
private initEvents() {
const { graph, extendEvents } = this;
const canvas: Canvas = graph.get('canvas');
// canvas.set('draggable', true);
const el = canvas.get('el');
const canvasHandler: Fun = wrapBehavior(this, 'onCanvasEvents') as Fun;
const originHandler = wrapBehavior(this, 'onExtendEvents');
const wheelHandler = wrapBehavior(this, 'onWheelEvent');
// each(EVENTS, event => {
// canvas.on(event, canvasHandler);
// });
canvas.on('*', canvasHandler);
this.canvasHandler = canvasHandler;
extendEvents.push(addEventListener(el, 'DOMMouseScroll', wheelHandler));
extendEvents.push(addEventListener(el, 'mousewheel', wheelHandler));
if (typeof window !== 'undefined') {
extendEvents.push(addEventListener(window as any, 'keydown', originHandler));
extendEvents.push(addEventListener(window as any, 'keyup', originHandler));
extendEvents.push(addEventListener(window as any, 'focus', originHandler));
}
}
// 获取 shape 的 item 对象
private static getItemRoot<T extends ShapeBase>(shape: any): T {
while (shape && !shape.get('item')) {
shape = shape.get('parent');
}
return shape;
}
/**
* canvas
* @param evt
*/
protected onCanvasEvents(evt: IG6GraphEvent) {
const { graph } = this;
const canvas = graph.get('canvas');
const { target } = evt;
const eventType = evt.type;
/**
* (clientX, clientY):
* (canvasX, canvasY): <canvas>
* (x, y): , model x, y
*/
evt.canvasX = evt.x;
evt.canvasY = evt.y;
let point = { x: evt.canvasX, y: evt.canvasY };
const group: Group = graph.get('group');
let matrix: Matrix = group.getMatrix();
if (!matrix) {
matrix = [1, 0, 0, 0, 1, 0, 0, 0, 1];
}
if (isViewportChanged(matrix)) {
point = graph.getPointByClient(evt.clientX, evt.clientY);
}
evt.x = point.x;
evt.y = point.y;
evt.currentTarget = graph;
if (target === canvas) {
if (eventType === 'mousemove') {
this.handleMouseMove(evt, 'canvas');
}
evt.target = canvas;
evt.item = null;
graph.emit(eventType, evt);
graph.emit(`canvas:${eventType}`, evt);
return;
}
const itemShape: ShapeBase = EventController.getItemRoot(target);
if (!itemShape) {
graph.emit(eventType, evt);
return;
}
const item = itemShape.get('item');
if (item.destroyed) {
return;
}
const type = item.getType();
// 事件target是触发事件的Shape实例item是触发事件的item实例
evt.target = target;
evt.item = item;
if (evt.canvasX === evt.x && evt.canvasY === evt.y) {
const canvasPoint = graph.getCanvasByPoint(evt.x, evt.y);
evt.canvasX = canvasPoint.x;
evt.canvasY = canvasPoint.y;
}
// emit('click', evt);
graph.emit(eventType, evt);
if (evt.name && !evt.name.includes(':')) graph.emit(`${type}:${eventType}`, evt);
// emit('node:click', evt)
else graph.emit(evt.name, evt); // emit('text-shape:click', evt)
if (eventType === 'dragstart') {
this.dragging = true;
}
if (eventType === 'dragend') {
this.dragging = false;
}
if (eventType === 'mousemove') {
this.handleMouseMove(evt, type);
}
}
/**
*
* @param evt
*/
protected onExtendEvents(evt: IG6GraphEvent) {
this.graph.emit(evt.type, evt);
}
/**
*
* @param evt
*/
protected onWheelEvent(evt: IG6GraphEvent) {
if (isNil(evt.wheelDelta)) {
evt.wheelDelta = -evt.detail;
}
this.graph.emit('wheel', evt);
}
/**
*
* @param evt
* @param type item
*/
private handleMouseMove(evt: IG6GraphEvent, type: string) {
const { graph, preItem } = this;
const canvas: Canvas = graph.get('canvas');
const item = (evt.target as any) === canvas ? null : evt.item;
evt = cloneEvent(evt) as IG6GraphEvent;
// 从前一个item直接移动到当前item触发前一个item的leave事件
if (preItem && preItem !== item && !preItem.destroyed) {
evt.item = preItem;
this.emitCustomEvent(preItem.getType(), 'mouseleave', evt);
if (this.dragging) {
this.emitCustomEvent(preItem.getType(), 'dragleave', evt);
}
}
// 从一个item或canvas移动到当前item触发当前item的enter事件
if (item && preItem !== item) {
evt.item = item;
this.emitCustomEvent(type, 'mouseenter', evt);
if (this.dragging) {
this.emitCustomEvent(type, 'dragenter', evt);
}
}
this.preItem = item;
}
/**
* graph emit
* @param itemType item
* @param eventType
* @param evt
*/
private emitCustomEvent(itemType: string, eventType: string, evt: IG6GraphEvent) {
evt.type = eventType;
this.graph.emit(`${itemType}:${eventType}`, evt);
}
public destroy() {
const { graph, canvasHandler, extendEvents } = this;
const canvas: Canvas = graph.get('canvas');
// each(EVENTS, event => {
// canvas.off(event, canvasHandler);
// });
canvas.off('*', canvasHandler);
each(extendEvents, (event) => {
event.remove();
});
this.dragging = false;
this.preItem = null;
this.extendEvents.length = 0;
(this.canvasHandler as Fun | null) = null;
this.destroyed = true;
}
}

View File

@ -0,0 +1,6 @@
export { default as ModeController } from './mode';
export { default as ViewController } from './view';
export { default as EventController } from './event';
export { default as ItemController } from './item';
export { default as LayoutController } from './layout';
export { default as StateController } from './state';

View File

@ -0,0 +1,624 @@
import { Group } from '@antv/g-canvas';
import { clone, deepMix, each, isArray, isObject, isString, upperFirst } from '@antv/util';
import Edge from '../../item/edge';
import Node from '../../item/node';
import Combo from '../../item/combo';
import {
EdgeConfig,
Item,
ITEM_TYPE,
ModelConfig,
NodeConfig,
NodeMap,
ComboTree,
ComboConfig,
} from '../../types';
import Graph from '../graph';
import { IEdge, INode, ICombo } from '../../interface/item';
import { traverseTreeUp, traverseTree, getComboBBox } from '../../util/graphic';
const NODE = 'node';
const EDGE = 'edge';
const VEDGE = 'vedge';
const COMBO = 'combo';
const CFG_PREFIX = 'default';
const MAPPER_SUFFIX = 'Mapper';
const STATE_SUFFIX = 'stateStyles';
const { hasOwnProperty } = Object;
type Id = string | Item | undefined;
export default class ItemController {
private graph: Graph;
public destroyed: boolean;
constructor(graph: Graph) {
this.graph = graph;
this.destroyed = false;
}
/**
* Item
*
* @param {ITEM_TYPE} type node edge
* @param {(NodeConfig & EdgeConfig)} model
* @returns {(Item)}
* @memberof ItemController
*/
public addItem<T extends Item>(type: ITEM_TYPE, model: ModelConfig) {
const { graph } = this;
const vType = type === VEDGE ? EDGE : type;
const parent: Group = graph.get(`${vType}Group`) || graph.get('group');
const upperType = upperFirst(vType);
let item: Item | null = null;
// 获取 this.get('styles') 中的值
let styles = graph.get(vType + upperFirst(STATE_SUFFIX)) || {};
const defaultModel = graph.get(CFG_PREFIX + upperType);
if (model[STATE_SUFFIX]) {
// 设置 this.get('styles') 中的值
styles = model[STATE_SUFFIX];
}
const mapper = graph.get(vType + MAPPER_SUFFIX);
if (mapper) {
const mappedModel = mapper(model);
if (mappedModel[STATE_SUFFIX]) {
// 设置 this.get('styles') 中的值
styles = mappedModel[STATE_SUFFIX];
delete mappedModel[STATE_SUFFIX];
}
// 如果配置了 defaultEdge 或 defaultNode则将默认配置的数据也合并进去
model = deepMix({}, defaultModel, model, mappedModel);
} else if (defaultModel) {
// 很多布局会直接修改原数据模型,所以不能用 merge 的形式,逐个写入原 model 中
each(defaultModel, (val, cfg) => {
if (!hasOwnProperty.call(model, cfg)) {
if (isObject(val)) {
model[cfg] = clone(val);
} else {
model[cfg] = defaultModel[cfg];
}
}
});
}
graph.emit('beforeadditem', { type, model });
if (type === EDGE || type === VEDGE) {
let source: Id;
let target: Id;
source = (model as EdgeConfig).source; // eslint-disable-line prefer-destructuring
target = (model as EdgeConfig).target; // eslint-disable-line prefer-destructuring
if (source && isString(source)) {
source = graph.findById(source);
}
if (target && isString(target)) {
target = graph.findById(target);
}
if (!source || !target) {
console.warn(`The source or target node of edge ${model.id} does not exist!`);
return;
}
if ((source as Item).getType && (source as Item).getType() === 'combo') {
model.isComboEdge = true;
graph.updateCombo(source as ICombo);
}
if ((target as Item).getType && (target as Item).getType() === 'combo') {
model.isComboEdge = true;
graph.updateCombo(target as ICombo);
}
item = new Edge({
model,
source,
target,
styles,
linkCenter: graph.get('linkCenter'),
group: parent.addGroup(),
});
} else if (type === NODE) {
item = new Node({
model,
styles,
group: parent.addGroup(),
});
} else if (type === COMBO) {
const children: ComboTree[] = (model as ComboConfig).children;
const comboBBox = getComboBBox(children, graph);
model.x = comboBBox.x || model.x || Math.random() * 100;
model.y = comboBBox.y || model.y || Math.random() * 100;
const comboGroup = parent.addGroup();
comboGroup.setZIndex((model as ComboConfig).depth as number);
item = new Combo({
model,
styles,
bbox: comboBBox,
group: comboGroup,
});
const comboModel = item.getModel();
(children || []).forEach((child) => {
const childItem = graph.findById(child.id) as ICombo | INode;
(item as ICombo).addChild(childItem);
child.depth = (comboModel.depth as number) + 2;
});
// collapse the combo if the collapsed is true in the model
if (model.collapsed) {
setTimeout(() => {
graph.collapseCombo(item as ICombo);
this.updateCombo(item as ICombo, []);
}, 250);
}
}
if (item) {
graph.get(`${type}s`).push(item);
graph.get('itemMap')[item.get('id')] = item;
graph.emit('afteradditem', { item, model });
// eslint-disable-next-line consistent-return
return item as T;
}
}
/**
*
*
* @param {Item} item ID
* @param {(EdgeConfig | Partial<NodeConfig>)} cfg
* @returns
* @memberof ItemController
*/
public updateItem(item: Item | string, cfg: EdgeConfig | Partial<NodeConfig>) {
const { graph } = this;
if (isString(item)) {
item = graph.findById(item) as Item;
}
if (!item || item.destroyed) {
return;
}
// 更新的 item 的类型
let type = '';
if (item.getType) type = item.getType();
const mapper = graph.get(type + MAPPER_SUFFIX);
const model = item.getModel();
const isOnlyMove = item.isOnlyMove(cfg);
if (mapper) {
const result: ModelConfig = deepMix({}, model, cfg);
const mappedModel: ModelConfig = mapper(result);
// 将 update 时候用户传入的参数与mapperModel做deepMix以便复用之前设置的参数值
const newModel: ModelConfig = deepMix({}, model, mappedModel, cfg);
if (mappedModel[STATE_SUFFIX]) {
item.set('styles', newModel[STATE_SUFFIX]);
delete newModel[STATE_SUFFIX];
}
each(newModel, (val, key) => {
cfg[key] = val;
});
} else {
// merge update传进来的对象参数model中没有的数据不做处理对象和字符串值也不做处理直接替换原来的
each(cfg, (val, key) => {
if (model[key]) {
if (isObject(val) && !isArray(val)) {
cfg[key] = { ...(model[key] as Object), ...(cfg[key] as Object) };
}
}
});
}
// emit beforeupdateitem 事件
graph.emit('beforeupdateitem', { item, cfg });
if (type === EDGE) {
// 若是边要更新source || target, 为了不影响示例内部model并且重新计算startPoint和endPoint手动设置
if (cfg.source) {
let source: INode = cfg.source as INode;
if (isString(source)) {
source = graph.findById(source) as INode;
}
(item as IEdge).setSource(source);
}
if (cfg.target) {
let target: INode = cfg.target as INode;
if (isString(target)) {
target = graph.findById(target) as INode;
}
(item as IEdge).setTarget(target);
}
item.update(cfg);
}
// item.update(cfg);
if (type === NODE || type === COMBO) {
item.update(cfg, isOnlyMove);
const edges: IEdge[] = (item as INode).getEdges();
each(edges, (edge: IEdge) => {
edge.refresh();
});
}
graph.emit('afterupdateitem', { item, cfg });
}
/**
* combo combo
*
* @param {ICombo} combo ID
* @returns
* @memberof ItemController
*/
public updateCombo(combo: ICombo | string, children: ComboTree[]) {
const { graph } = this;
if (isString(combo)) {
combo = graph.findById(combo) as ICombo;
}
if (!combo || combo.destroyed) {
return;
}
const comboBBox = getComboBBox(children, graph);
combo.set('bbox', comboBBox);
combo.update({
x: comboBBox.x,
y: comboBBox.y,
});
const combEdges = combo.getEdges() || [];
const length = combEdges.length;
for (let i = 0; i < length; i++) {
const edge = combEdges[i];
edge?.refresh();
}
}
/**
* combo
*/
public collapseCombo(combo: ICombo | string) {
const graph = this.graph;
if (isString(combo)) {
combo = graph.findById(combo) as ICombo;
}
const children = (combo as ICombo).getChildren();
children.nodes.forEach((node) => {
graph.hideItem(node);
});
children.combos.forEach((c) => {
graph.hideItem(c);
});
}
/**
* combo
* combo
*/
public expandCombo(combo: ICombo | string) {
const graph = this.graph;
if (isString(combo)) {
combo = graph.findById(combo) as ICombo;
}
const children = (combo as ICombo).getChildren();
children.nodes.forEach((node) => {
graph.showItem(node);
});
children.combos.forEach((c) => {
if (c.getModel().collapsed) {
c.show();
} else {
graph.showItem(c);
}
});
}
/**
*
*
* @param {Item} item item ID
* @returns {void}
* @memberof ItemController
*/
public removeItem(item: Item | string): void {
const { graph } = this;
if (isString(item)) {
item = graph.findById(item);
}
if (!item || item.destroyed) {
return;
}
const itemModel = clone(item.getModel());
graph.emit('beforeremoveitem', { item: itemModel });
let type = '';
if (item.getType) type = item.getType();
const items = graph.get(`${type}s`);
const index = items.indexOf(item);
if (index > -1) items.splice(index, 1);
if (type === EDGE) {
const vitems = graph.get(`v${type}s`);
const vindex = vitems.indexOf(item);
if (vindex > -1) vitems.splice(vindex, 1);
}
const itemId: string = item.get('id');
const itemMap: NodeMap = graph.get('itemMap');
delete itemMap[itemId];
const comboTrees = graph.get('comboTrees');
const id = item.get('id');
if (type === NODE) {
const comboId = item.getModel().comboId as string;
if (comboTrees) {
let brothers = comboTrees;
let found = false; // the flag to terminate the forEach circulation
// remove the node from the children array of its parent fromt he tree
comboTrees.forEach((ctree) => {
if (found) return;
traverseTree<ComboTree>(ctree, (combo) => {
if (combo.id === id && brothers) {
const bidx = brothers.indexOf(combo);
brothers.splice(bidx, 1);
found = true;
return false; // terminate the traverse
}
brothers = combo.children;
return true;
});
});
}
// 若移除的是节点,需要将与之相连的边一同删除
const edges = (item as INode).getEdges();
for (let i = edges.length - 1; i >= 0; i--) {
graph.removeItem(edges[i], false);
}
if (comboId) graph.updateCombo(comboId);
} else if (type === COMBO) {
const parentId = item.getModel().parentId as string;
let comboInTree;
// find the subtree rooted at the item to be removed
let found = false; // the flag to terminate the forEach circulation
(comboTrees || []).forEach((ctree) => {
if (found) return;
traverseTree<ComboTree>(ctree, (combo) => {
if (combo.id === id) {
comboInTree = combo;
found = true;
return false; // terminate the traverse
}
return true;
});
});
comboInTree.removed = true;
if (comboInTree && comboInTree.children) {
comboInTree.children.forEach((child) => {
this.removeItem(child.id);
});
}
// 若移除的是 combo需要将与之相连的边一同删除
const edges = (item as ICombo).getEdges();
for (let i = edges.length; i >= 0; i--) {
graph.removeItem(edges[i], false);
}
if (parentId) graph.updateCombo(parentId);
}
item.destroy();
graph.emit('afterremoveitem', { item: itemModel });
}
/**
* item
*
* @param {Item} item Item
* @param {string} state
* @param {boolean} value
* @returns {void}
* @memberof ItemController
*/
public setItemState(item: Item, state: string, value: string | boolean): void {
const { graph } = this;
let stateName = state;
if (isString(value)) {
stateName = `${state}:${value}`;
}
// 已经存在要设置的 state或不存在 state 的样式为 undefined
if (item.hasState(stateName) === value || (isString(value) && item.hasState(stateName))) {
return;
}
graph.emit('beforeitemstatechange', { item, state: stateName, enabled: value });
item.setState(state, value);
graph.autoPaint();
graph.emit('afteritemstatechange', { item, state: stateName, enabled: value });
}
/**
*
* @param {Item} item id或元素实例
* @param state
*/
public priorityState(item: Item | string, state: string): void {
const { graph } = this;
let currentItem = item;
if (isString(item)) {
currentItem = graph.findById(item);
}
// 先取消已有的 state
this.setItemState(currentItem as Item, state, false);
// 再设置state则此时该优先级为最高
this.setItemState(currentItem as Item, state, true);
}
/**
*
*
* @param {Item} item Item
* @param {string[]} states
* @memberof ItemController
*/
public clearItemStates(item: Item | string, states?: string | string[]): void {
const { graph } = this;
if (isString(item)) {
item = graph.findById(item);
}
graph.emit('beforeitemstatesclear', { item, states });
item.clearStates(states);
graph.emit('afteritemstatesclear', { item, states });
}
/**
* Item
*
* @param {Item} item Item ID
* @memberof ItemController
*/
public refreshItem(item: Item | string): void {
const { graph } = this;
if (isString(item)) {
item = graph.findById(item);
}
graph.emit('beforeitemrefresh', { item });
// 调用 Item 的 refresh 方法,实现刷新功能
item.refresh();
graph.emit('afteritemrefresh', { item });
}
/**
* graph combos comboTree combos
*
* @param {ComboTree[]} comboTrees graph combos comboTree
* @param {ComboConfig[]} comboModels combos
* @memberof ItemController
*/
public addCombos(comboTrees: ComboTree[], comboModels: ComboConfig[]) {
const { graph } = this;
(comboTrees || []).forEach((ctree: ComboTree) => {
traverseTreeUp<ComboTree>(ctree, (child) => {
let comboModel;
comboModels.forEach((model) => {
if (model.id === child.id) {
model.children = child.children;
model.depth = child.depth;
comboModel = model;
}
});
if (comboModel) {
this.addItem('combo', comboModel);
}
return true;
});
});
const comboGroup = graph.get('comboGroup');
if (comboGroup) comboGroup.sort();
}
/**
* Item的显示状态
*
* @param {Item} item Item ID
* @param {boolean} visible
* @memberof ItemController
*/
public changeItemVisibility(item: Item | string, visible: boolean): Item {
const { graph } = this;
if (isString(item)) {
item = graph.findById(item);
}
if (!item) {
console.warn('The item to be shown or hidden does not exist!');
return;
}
graph.emit('beforeitemvisibilitychange', { item, visible });
item.changeVisibility(visible);
if (item.getType && item.getType() === NODE) {
const edges = (item as INode).getEdges();
each(edges, (edge: IEdge) => {
// 若隐藏节点,则将与之关联的边也隐藏
// 若显示节点,则将与之关联的边也显示,但是需要判断边两端的节点都是可见的
if (visible && !(edge.get('source').isVisible() && edge.get('target').isVisible())) {
return;
}
this.changeItemVisibility(edge, visible);
});
} else if (item.getType && item.getType() === COMBO) {
const comboTrees = graph.get('comboTrees');
const id = item.get('id');
let children = [];
let found = false; // flag the terminate the forEach
(comboTrees || []).forEach((ctree) => {
if (found) return;
if (!ctree.children || ctree.children.length === 0) return;
traverseTree<ComboTree>(ctree, (combo) => {
if (combo.id === id) {
children = combo.children;
found = true;
return false; // terminate the traverse
}
return true;
});
});
children.forEach((child) => {
const childItem = graph.findById(child.id);
this.changeItemVisibility(childItem, visible);
});
const edges = (item as INode).getEdges();
each(edges, (edge: IEdge) => {
// 若隐藏 combo则将与 combo 本身关联的边也隐藏
// 若显示 combo则将与 combo 本身关联的边也显示,但是需要判断边两端的节点都是可见的
if (visible && !(edge.get('source').isVisible() && edge.get('target').isVisible())) {
return;
}
this.changeItemVisibility(edge, visible);
});
}
graph.emit('afteritemvisibilitychange', { item, visible });
return item;
}
public destroy() {
(this.graph as Graph | null) = null;
this.destroyed = true;
}
}

View File

@ -0,0 +1,227 @@
import { isNaN } from '../../util/base';
import { GraphData } from '../../types';
import { IGraph } from '../../interface/graph';
export default abstract class LayoutController {
public graph: IGraph;
public destroyed: boolean;
private layoutCfg;
private layoutType: string;
private layoutMethod;
private data;
constructor(graph: IGraph) {
this.graph = graph;
this.layoutCfg = graph.get('layout') || {};
this.layoutType = this.layoutCfg.type;
this.initLayout();
}
// eslint-disable-next-line class-methods-use-this
private initLayout() {
// no data before rendering
}
public getLayoutType(): string {
return this.layoutCfg.type;
}
/**
* @param {function} success callback
* @return {boolean} 使web worker布局
*/
public abstract layout(success?: () => void): boolean;
// 绘制
public refreshLayout() {
const { graph } = this;
if (graph.get('animate')) {
graph.positionsAnimate();
} else {
graph.refreshPositions();
}
}
// 更新布局参数
public abstract updateLayoutCfg(cfg);
// 更换布局
public changeLayout(layoutType: string) {
const { graph, layoutMethod } = this;
this.layoutType = layoutType;
this.layoutCfg = graph.get('layout') || {};
this.layoutCfg.type = layoutType;
if (layoutMethod) {
layoutMethod.destroy();
}
this.layout();
}
// 更换数据
public changeData() {
const { layoutMethod } = this;
if (layoutMethod) {
layoutMethod.destroy();
}
this.layout();
}
// 销毁布局,不能使用 this.destroy因为 controller 还需要被使用,只是把布局算法销毁
public destroyLayout() {
const { layoutMethod, graph } = this;
if (layoutMethod) {
layoutMethod.destroy();
}
graph.set('layout', undefined);
this.layoutCfg = undefined;
this.layoutType = undefined;
this.layoutMethod = undefined;
}
// 从 this.graph 获取数据
public setDataFromGraph(): GraphData {
const nodes = [];
const edges = [];
const combos = [];
const nodeItems = this.graph.getNodes();
const edgeItems = this.graph.getEdges();
const comboItems = this.graph.getCombos();
const nodeLength = nodeItems.length;
for (let i = 0; i < nodeLength; i++) {
const nodeItem = nodeItems[i];
if (!nodeItem.isVisible()) continue;
const model = nodeItem.getModel();
nodes.push(model);
}
const edgeLength = edgeItems.length;
for (let i = 0; i < edgeLength; i++) {
const edgeItem = edgeItems[i];
if (edgeItem.destroyed || !edgeItem.isVisible()) continue;
const model = edgeItem.getModel();
if (!model.isComboEdge) edges.push(model);
}
const comboLength = comboItems.length;
for (let i = 0; i < comboLength; i++) {
const comboItem = comboItems[i];
if (comboItem.destroyed || !comboItem.isVisible()) continue;
const model = comboItem.getModel();
combos.push(model);
}
return { nodes, edges, combos } as GraphData;
}
// 重新布局
public relayout(reloadData?: boolean) {
const { graph, layoutMethod, layoutCfg } = this;
if (reloadData) {
this.data = this.setDataFromGraph();
const { nodes } = this.data;
if (!nodes) {
return false;
}
this.initPositions(layoutCfg.center, nodes);
layoutMethod.init(this.data);
}
if (this.layoutType === 'force') {
layoutMethod.ticking = false;
layoutMethod.forceSimulation.stop();
}
graph.emit('beforelayout');
layoutMethod.execute(reloadData);
if (this.layoutType !== 'force' && !layoutMethod.enableTick) {
graph.emit('afterlayout');
}
this.refreshLayout();
}
// 控制布局动画
// eslint-disable-next-line class-methods-use-this
public layoutAnimate() {}
// 将当前节点的平均中心移动到原点
public moveToZero() {
const { graph } = this;
const data = graph.get('data');
const { nodes } = data;
if (nodes[0].x === undefined || nodes[0].x === null || isNaN(nodes[0].x)) {
return;
}
const meanCenter = [0, 0];
const nodeLength = nodes.length;
for (let i = 0; i < nodeLength; i++) {
const node = nodes[i];
meanCenter[0] += node.x;
meanCenter[1] += node.y;
}
meanCenter[0] /= nodes.length;
meanCenter[1] /= nodes.length;
for (let i = 0; i < nodeLength; i++) {
const node = nodes[i];
node.x -= meanCenter[0];
node.y -= meanCenter[1];
}
}
// 初始化节点到 center 附近
public initPositions(center, nodes): boolean {
const { graph } = this;
if (!nodes) {
return false;
}
let allHavePos = true;
const width = graph.get('width') * 0.85;
const height = graph.get('height') * 0.85;
const nodeNum = nodes.length;
const horiNum = Math.ceil(Math.sqrt(nodeNum) * (width / height));
const vertiNum = Math.ceil(nodeNum / horiNum);
let horiGap = width / (horiNum - 1);
let vertiGap = height / (vertiNum - 1);
if (!isFinite(horiGap) || !horiGap) horiGap = 0;
if (!isFinite(vertiGap) || !horiGap) vertiGap = 0;
const beginX = center[0] - width / 2;
const beginY = center[1] - height / 2;
const nodeLength = nodes.length;
for (let i = 0; i < nodeLength; i++) {
const node = nodes[i];
if (isNaN(node.x)) {
allHavePos = false;
node.x = (i % horiNum) * horiGap + beginX;
}
if (isNaN(node.y)) {
allHavePos = false;
node.y = Math.floor(i / horiNum) * vertiGap + beginY;
}
}
return allHavePos;
}
public destroy() {
const { layoutMethod } = this;
this.graph = null;
if (layoutMethod) {
layoutMethod.destroy();
layoutMethod.destroyed = true;
}
this.destroyed = true;
}
}

View File

@ -0,0 +1,209 @@
import { each, isArray, isString } from '@antv/util';
import Behavior from '../../behavior/behavior';
import { IBehavior } from '../../interface/behavior';
import { IGraph } from '../../interface/graph';
import { ModeType, Modes } from '../../types';
import Graph from '../graph';
export default class ModeController {
private graph: Graph;
public destroyed: boolean;
/**
* modes = {
* default: [ 'drag-node', 'zoom-canvas' ],
* edit: [ 'drag-canvas', {
* type: 'brush-select',
* trigger: 'ctrl'
* }]
* }
*
* @private
* @type {Modes}
* @memberof Mode
*/
public modes: Modes;
/**
* mode = 'drag-node'
*
* @private
* @type {string}
* @memberof Mode
*/
public mode: string;
private currentBehaves: IBehavior[];
constructor(graph: Graph) {
this.graph = graph;
this.destroyed = false;
this.modes = graph.get('modes') || {
default: [],
};
this.formatModes();
this.mode = graph.get('defaultMode') || 'default';
this.currentBehaves = [];
this.setMode(this.mode);
}
private formatModes() {
const { modes } = this;
each(modes, (mode) => {
each(mode, (behavior, i) => {
if (isString(behavior)) {
mode[i] = { type: behavior };
}
});
});
}
private setBehaviors(mode: string) {
const { graph } = this;
const behaviors = this.modes[mode];
const behaves: IBehavior[] = [];
let behave: IBehavior;
each(behaviors || [], (behavior) => {
const BehaviorInstance = Behavior.getBehavior(behavior.type);
if (!BehaviorInstance) {
return;
}
behave = new BehaviorInstance(behavior);
if (behave) {
behave.bind(graph as IGraph);
behaves.push(behave);
}
});
this.currentBehaves = behaves;
}
private static mergeBehaviors(modeBehaviors: ModeType[], behaviors: ModeType[]): ModeType[] {
each(behaviors, (behavior) => {
if (modeBehaviors.indexOf(behavior) < 0) {
if (isString(behavior)) {
behavior = { type: behavior };
}
modeBehaviors.push(behavior);
}
});
return modeBehaviors;
}
private static filterBehaviors(modeBehaviors: ModeType[], behaviors: ModeType[]): ModeType[] {
const result: ModeType[] = [];
modeBehaviors.forEach((behavior) => {
let type: string = '';
if (isString(behavior)) {
type = behavior;
} else {
// eslint-disable-next-line prefer-destructuring
type = behavior.type;
}
if (behaviors.indexOf(type) < 0) {
result.push(behavior);
}
});
return result;
}
public setMode(mode: string) {
const { modes, graph } = this;
const current = mode;
const behaviors = modes[current];
if (!behaviors) {
return;
}
graph.emit('beforemodechange', { mode });
each(this.currentBehaves, (behave) => {
behave.unbind(graph);
});
this.setBehaviors(current);
graph.emit('aftermodechange', { mode });
this.mode = mode;
}
public getMode(): string {
return this.mode;
}
/**
* Behavior
*
* @param {ModeType[]} behaviors
* @param {(ModeType[] | ModeType)} modes
* @param {boolean} isAdd
* @returns {Mode}
* @memberof Mode
*/
public manipulateBehaviors(
behaviors: ModeType[] | ModeType,
modes: string[] | string,
isAdd: boolean,
): ModeController {
let behaves: ModeType[];
if (!isArray(behaviors)) {
behaves = [behaviors];
} else {
behaves = behaviors;
}
if (isArray(modes)) {
each(modes, (mode) => {
if (!this.modes[mode]) {
if (isAdd) {
this.modes[mode] = behaves;
}
} else if (isAdd) {
this.modes[mode] = ModeController.mergeBehaviors(this.modes[mode] || [], behaves);
} else {
this.modes[mode] = ModeController.filterBehaviors(this.modes[mode] || [], behaves);
}
});
return this;
}
let currentMode = modes;
if (!modes) {
currentMode = this.mode; // isString(this.mode) ? this.mode : this.mode.type
}
if (!this.modes[currentMode]) {
if (isAdd) {
this.modes[currentMode] = behaves;
}
}
if (isAdd) {
this.modes[currentMode] = ModeController.mergeBehaviors(
this.modes[currentMode] || [],
behaves,
);
} else {
this.modes[currentMode] = ModeController.filterBehaviors(
this.modes[currentMode] || [],
behaves,
);
}
this.setMode(this.mode);
return this;
}
public destroy() {
(this.graph as Graph | null) = null;
(this.modes as Modes | null) = null;
(this.currentBehaves as IBehavior[] | null) = null;
this.destroyed = true;
}
}

View File

@ -0,0 +1,181 @@
import { each, isString } from '@antv/util';
import { Item, States } from '../../types';
import Graph from '../graph';
import { INode } from '../../interface/item';
interface CachedStates {
enabled: States;
disabled: States;
}
let timer: any = null;
export default class StateController {
private graph: Graph;
private cachedStates: CachedStates;
public destroyed: boolean;
constructor(graph: Graph) {
this.graph = graph;
/**
* this.cachedStates = {
* enabled: {
* hover: [Node]
* },
* disabled: {}
* }
*/
this.cachedStates = {
enabled: {},
disabled: {},
};
this.destroyed = false;
}
/**
* cache
*
* @private
* @param {Item} item
* @param {string} state
* @param {object} cache
* @returns
* @memberof State
*/
private static checkCache(item: Item, state: string, cache: { [key: string]: any }) {
if (!cache[state]) {
return;
}
const index = cache[state].indexOf(item);
if (index >= 0) {
cache[state].splice(index, 1);
}
}
/**
* state
*
* @private
* @param {Item} item Item
* @param {string} state
* @param {object} states
* @memberof State
*/
private static cacheState(item: Item, state: string, states: States) {
if (!states[state]) {
states[state] = [];
}
states[state].push(item as INode);
}
/**
* Item
*
* @param {Item} item Item实例
* @param {string} state
* @param {boolean} enabled
* @memberof State
*/
public updateState(item: Item, state: string, enabled: boolean) {
const { checkCache, cacheState } = StateController;
if (item.destroyed) {
return;
}
const { cachedStates } = this;
const enabledStates = cachedStates.enabled;
const disabledStates = cachedStates.disabled;
if (enabled) {
checkCache(item, state, disabledStates);
cacheState(item, state, enabledStates);
} else {
checkCache(item, state, enabledStates);
cacheState(item, state, disabledStates);
}
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
timer = null;
this.updateGraphStates();
}, 16);
}
/**
* states updateState state
*
* @param {Item} item
* @param {(string | string[])} states
* @param {boolean} enabled
* @memberof State
*/
public updateStates(item: Item, states: string | string[], enabled: boolean) {
if (isString(states)) {
this.updateState(item, states, enabled);
} else {
states.forEach((state) => {
this.updateState(item, state, enabled);
});
}
}
/**
* states
*
* @memberof State
*/
public updateGraphStates() {
const states = this.graph.get('states') as States;
const { cachedStates } = this;
each(cachedStates.disabled, (val, key) => {
if (states[key]) {
states[key] = states[key].filter((item) => val.indexOf(item) < 0 && !val.destroyed);
}
});
each(cachedStates.enabled, (val: INode[], key) => {
if (!states[key]) {
states[key] = val;
} else {
const map: { [key: string]: boolean } = {};
states[key].forEach((item) => {
if (!item.destroyed) {
map[item.get('id')] = true;
}
});
val.forEach((item: Item) => {
if (!item.destroyed) {
const id = item.get('id');
if (!map[id]) {
map[id] = true;
states[key].push(item as INode);
}
}
});
}
});
this.graph.emit('graphstatechange', { states });
this.cachedStates = {
enabled: {},
disabled: {},
};
}
public destroy() {
(this.graph as Graph | null) = null;
(this.cachedStates as CachedStates | null) = null;
if (timer) {
clearTimeout(timer);
}
timer = null;
this.destroyed = true;
}
}

View File

@ -0,0 +1,230 @@
import Canvas from '@antv/g-base/lib/abstract/canvas';
import { Point } from '@antv/g-base/lib/types';
import { Group } from '@antv/g-canvas';
import { isNumber, isString } from '@antv/util';
import { Item, Matrix, Padding, GraphAnimateConfig } from '../../types';
import { formatPadding } from '../../util/base';
import { applyMatrix, invertMatrix } from '../../util/math';
import Graph from '../graph';
import { modifyCSS } from '@antv/dom-util';
export default class ViewController {
private graph: Graph;
public destroyed: boolean = false;
constructor(graph: Graph) {
this.graph = graph;
this.destroyed = false;
}
// get view center coordinate
private getViewCenter(): Point {
const padding = this.getFormatPadding();
const { graph } = this;
const width: number = this.graph.get('width');
const height: number = graph.get('height');
return {
x: (width - padding[1] - padding[3]) / 2 + padding[3],
y: (height - padding[0] - padding[2]) / 2 + padding[0],
};
}
public fitCenter() {
const { graph } = this;
const group: Group = graph.get('group');
group.resetMatrix();
const bbox = group.getCanvasBBox();
if (bbox.width === 0 || bbox.height === 0) return;
const viewCenter = this.getViewCenter();
const groupCenter: Point = {
x: bbox.x + bbox.width / 2,
y: bbox.y + bbox.height / 2,
};
graph.translate(viewCenter.x - groupCenter.x, viewCenter.y - groupCenter.y);
}
// fit view graph
public fitView() {
const { graph } = this;
const padding = this.getFormatPadding();
const width: number = graph.get('width');
const height: number = graph.get('height');
const group: Group = graph.get('group');
group.resetMatrix();
const bbox = group.getCanvasBBox();
if (bbox.width === 0 || bbox.height === 0) return;
const viewCenter = this.getViewCenter();
const groupCenter: Point = {
x: bbox.x + bbox.width / 2,
y: bbox.y + bbox.height / 2,
};
graph.translate(viewCenter.x - groupCenter.x, viewCenter.y - groupCenter.y);
const w = (width - padding[1] - padding[3]) / bbox.width;
const h = (height - padding[0] - padding[2]) / bbox.height;
let ratio = w;
if (w > h) {
ratio = h;
}
graph.zoom(ratio, viewCenter);
}
public getFormatPadding(): number[] {
const padding = this.graph.get('fitViewPadding') as Padding;
return formatPadding(padding);
}
public focusPoint(point: Point, animate?: boolean, animateCfg?: GraphAnimateConfig) {
const viewCenter = this.getViewCenter();
const modelCenter = this.getPointByCanvas(viewCenter.x, viewCenter.y);
let viewportMatrix: Matrix = this.graph.get('group').getMatrix();
if (!viewportMatrix) viewportMatrix = [1, 0, 0, 0, 1, 0, 0, 0, 1];
if (animate) {
const dx = (modelCenter.x - point.x) * viewportMatrix[0];
const dy = (modelCenter.y - point.y) * viewportMatrix[4];
let lastX = 0;
let lastY = 0;
let newX = 0;
let newY = 0;
// 动画每次平移一点,直到目标位置
this.graph.get('canvas').animate(
(ratio) => {
newX = dx * ratio;
newY = dy * ratio;
this.graph.translate(newX - lastX, newY - lastY);
lastX = newX;
lastY = newY;
},
{
...animateCfg,
},
);
} else {
this.graph.translate(
(modelCenter.x - point.x) * viewportMatrix[0],
(modelCenter.y - point.y) * viewportMatrix[4],
);
}
}
/**
* Canvas
* @param canvasX canvas x
* @param canvasY canvas y
*/
public getPointByCanvas(canvasX: number, canvasY: number): Point {
let viewportMatrix: Matrix = this.graph.get('group').getMatrix();
if (!viewportMatrix) {
viewportMatrix = [1, 0, 0, 0, 1, 0, 0, 0, 1];
}
const point = invertMatrix({ x: canvasX, y: canvasY }, viewportMatrix);
return point;
}
/**
*
* @param clientX x
* @param clientY y
*/
public getPointByClient(clientX: number, clientY: number): Point {
const canvas: Canvas = this.graph.get('canvas');
const canvasPoint: Point = canvas.getPointByClient(clientX, clientY);
return this.getPointByCanvas(canvasPoint.x, canvasPoint.y);
}
/**
*
* @param x x
* @param y y
*/
public getClientByPoint(x: number, y: number): Point {
const canvas: Canvas = this.graph.get('canvas');
const canvasPoint = this.getCanvasByPoint(x, y);
const point = canvas.getClientByPoint(canvasPoint.x, canvasPoint.y);
// return { x: point.clientX, y: point.clientY };
return { x: point.x, y: point.y };
}
/**
* Canvas
* @param x x
* @param y y
*/
public getCanvasByPoint(x: number, y: number): Point {
let viewportMatrix: Matrix = this.graph.get('group').getMatrix();
if (!viewportMatrix) {
viewportMatrix = [1, 0, 0, 0, 1, 0, 0, 0, 1];
}
return applyMatrix({ x, y }, viewportMatrix);
}
/**
*
* @param item Item id
* @param {boolean} animate
* @param {GraphAnimateConfig} animateCfg
*/
public focus(item: string | Item, animate?: boolean, animateCfg?: GraphAnimateConfig) {
if (isString(item)) {
item = this.graph.findById(item);
}
const group: Group = item.get('group');
let matrix: Matrix = group.getMatrix();
if (!matrix) matrix = [1, 0, 0, 0, 1, 0, 0, 0, 1];
if (item) {
// 用实际位置而不是model中的x,y,防止由于拖拽等的交互导致model的x,y并不是当前的x,y
this.focusPoint(
{
x: matrix[6],
y: matrix[7],
},
animate,
animateCfg,
);
}
}
/**
* canvas
* @param width canvas
* @param height canvas
*/
public changeSize(width: number, height: number) {
const { graph } = this;
if (!isNumber(width) || !isNumber(height)) {
throw Error('invalid canvas width & height, please make sure width & height type is number');
}
graph.set({ width, height });
const canvas: Canvas = graph.get('canvas');
canvas.changeSize(width, height);
// change the size of grid plugin if it exists on graph
const plugins = graph.get('plugins');
plugins.forEach((plugin) => {
if (plugin.get('gridContainer')) {
const minZoom = graph.get('minZoom');
modifyCSS(plugin.get('container'), {
width: `${width}px`,
height: `${height}px`,
});
modifyCSS(plugin.get('gridContainer'), {
width: `${width / minZoom}px`,
height: `${height / minZoom}px`,
left: 0,
top: 0,
});
}
});
}
public destroy() {
(this.graph as Graph | null) = null;
this.destroyed = false;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,591 @@
import { Point } from '@antv/g-base/lib/types';
import Hierarchy from '@antv/hierarchy';
import { each, isString } from '@antv/util';
import { ITreeGraph } from '../interface/graph';
import { GraphData, Item, NodeConfig, ShapeStyle, TreeGraphData, GraphOptions } from '../types';
import { radialLayout, traverseTree } from '../util/graphic';
import { ViewController } from './controller';
import Graph, { PrivateGraphOption } from './graph';
export default abstract class TreeGraph extends Graph implements ITreeGraph {
private layoutAnimating: boolean;
constructor(cfg: GraphOptions) {
super(cfg);
this.layoutAnimating = false;
// 用于缓存动画结束后需要删除的节点
this.set('removeList', []);
this.set('layoutMethod', this.getLayout());
}
/**
* initCanvas PC Mobile
*/
public abstract initCanvas(): void;
/**
* Layout
*/
private getLayout() {
const layout = this.get('layout');
if (!layout) {
return null;
}
if (typeof layout === 'function') {
return layout;
}
if (!layout.type) {
layout.type = 'dendrogram';
}
if (!layout.direction) {
layout.direction = 'TB';
}
if (layout.radial) {
return (data: any) => {
const layoutData = Hierarchy[layout.type](data, layout);
radialLayout(layoutData);
return layoutData;
};
}
return (data: any) => Hierarchy[layout.type](data, layout);
}
/**
*
* @param children
* @param child Item
*/
private static indexOfChild(children: TreeGraphData[], id: string): number {
let index = -1;
// eslint-disable-next-line consistent-return
each(children, (former, i) => {
if (id === former.id) {
index = i;
return false;
}
});
return index;
}
public getDefaultCfg(): Partial<PrivateGraphOption> {
const cfg = super.getDefaultCfg();
// 树图默认打开动画
cfg.animate = true;
return cfg;
}
/**
* 🌲
* @param treeData
* @param parent
* @param animate
*/
private innerAddChild(treeData: TreeGraphData, parent: Item | undefined, animate: boolean): Item {
const self = this;
const model = treeData.data;
if (model) {
// model 中应存储真实的数据,特别是真实的 children
model.x = treeData.x;
model.y = treeData.y;
model.depth = treeData.depth;
}
const node = self.addItem('node', model!, false);
if (parent) {
node.set('parent', parent);
if (animate) {
const origin = parent.get('originAttrs');
if (origin) {
node.set('originAttrs', origin);
} else {
const parentModel = parent.getModel();
node.set('originAttrs', {
x: parentModel.x,
y: parentModel.y,
});
}
}
const childrenList = parent.get('children');
if (!childrenList) {
parent.set('children', [node]);
} else {
childrenList.push(node);
}
self.addItem(
'edge',
{
source: parent,
target: node,
id: `${parent.get('id')}:${node.get('id')}`,
},
false,
);
}
// 渲染到视图上应参考布局的children, 避免多绘制了收起的节点
each(treeData.children || [], child => {
self.innerAddChild(child, node, animate);
});
self.emit('afteraddchild', { item: node, parent });
return node;
}
/**
*
* @param data
* @param parent
* @param animate
*/
private innerUpdateChild(data: TreeGraphData, parent: Item | undefined, animate: boolean) {
const self = this;
const current = self.findById(data.id);
// 若子树不存在,整体添加即可
if (!current) {
self.innerAddChild(data, parent, animate);
return;
}
// 更新新节点下所有子节点
each(data.children || [], (child: TreeGraphData) => {
self.innerUpdateChild(child, current, animate);
});
// 用现在节点的children实例来删除移除的子节点
const children = current.get('children');
if (children) {
const len = children.length;
if (len > 0) {
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i].getModel();
if (TreeGraph.indexOfChild(data.children || [], child.id) === -1) {
self.innerRemoveChild(
child.id,
{
x: data.x!,
y: data.y!,
},
animate,
);
// 更新父节点下缓存的子节点 item 实例列表
children.splice(i, 1);
}
}
}
}
let oriX: number;
let oriY: number;
if (current.get('originAttrs')) {
oriX = current.get('originAttrs').x;
oriY = current.get('originAttrs').y;
}
const model = current.getModel();
if (animate) {
// 如果有动画,先缓存节点运动再更新节点
current.set('originAttrs', {
x: model.x,
y: model.y,
});
}
current.set('model', data.data);
if (oriX !== data.x || oriY !== data.y) {
current.updatePosition({ x: data.x, y: data.y });
}
}
/**
* Item对象
* @param id
* @param to
* @param animate
*/
private innerRemoveChild(id: string, to: Point, animate: boolean) {
const self = this;
const node: Item = self.findById(id);
if (!node) {
return;
}
each(node.get('children'), child => {
self.innerRemoveChild(child.getModel().id, to, animate);
});
if (animate) {
const model = node.getModel();
node.set('to', to);
node.set('originAttrs', { x: model.x, y: model.y });
self.get('removeList').push(node);
} else {
self.removeItem(node);
}
}
/**
*
* @param {object} data
*/
public changeData(data?: GraphData | TreeGraphData): any {
const self = this;
// 更改数据源后,取消所有状态
this.getNodes().map(node => self.clearItemStates(node));
this.getEdges().map(edge => self.clearItemStates(edge));
if (data) {
self.data(data);
self.render();
} else {
self.layout(this.get('fitView'));
}
}
/**
* updateLayout
*
* @param {object} layout
*/
public changeLayout(layout: any) {
// eslint-disable-next-line no-console
console.warn(
'Please call updateLayout instead of changeLayout. changeLayout will be discarded soon',
);
const self = this;
self.updateLayout(layout);
}
/**
*
* @param {object} layout
*/
public updateLayout(layout: any) {
const self = this;
if (!layout) {
// eslint-disable-next-line no-console
console.warn('layout cannot be null');
return;
}
self.set('layout', layout);
self.set('layoutMethod', self.getLayout());
self.layout();
}
/**
* layout
* data
* @param {boolean} fitView
*/
public refreshLayout(fitView?: boolean) {
// eslint-disable-next-line no-console
console.warn(
'Please call layout instead of refreshLayout. refreshLayout will be discarded soon',
);
const self = this;
self.layout(fitView);
}
/**
* data
* @param {boolean} fitView
*/
public layout(fitView?: boolean) {
const self = this;
const data: TreeGraphData = self.get('data');
const layoutMethod = self.get('layoutMethod');
const layoutData = layoutMethod(data, self.get('layout'));
const animate: boolean = self.get('animate');
self.emit('beforerefreshlayout', { data, layoutData });
self.emit('beforelayout');
self.innerUpdateChild(layoutData, undefined, animate);
if (fitView) {
const viewController: ViewController = self.get('viewController');
viewController.fitView();
}
if (!animate) {
// 如果没有动画,目前仅更新了节点的位置,刷新一下边的样式
self.refresh();
self.paint();
} else {
self.layoutAnimate(layoutData);
}
self.emit('afterrefreshlayout', { data, layoutData });
self.emit('afterlayout');
}
/**
* id
* @param {TreeGraphData} data
* @param {string} parent id
*/
public addChild(data: TreeGraphData, parent: string | Item): void {
const self = this;
self.emit('beforeaddchild', { model: data, parent });
// 将数据添加到源数据中走changeData方法
if (!isString(parent)) {
parent = parent.get('id') as string;
}
const parentData = self.findDataById(parent);
if (parentData) {
if (!parentData.children) {
parentData.children = [];
}
parentData.children.push(data);
self.changeData();
}
}
/**
*
* @param {TreeGraphData[]} data
* @param {string} parent id
*/
public updateChildren(data: TreeGraphData[], parentId: string): void {
const self = this;
// 如果没有父节点或找不到该节点是全量的更新直接重置data
if (!parentId || !self.findById(parentId)) {
console.warn(`Update children failed! There is no node with id '${parentId}'`);
return;
}
const parentModel = self.findDataById(parentId) as NodeConfig;
parentModel.children = data;
self.changeData();
}
/**
*
* @param {TreeGraphData} data
* @param {string} parentId id
*/
public updateChild(data: TreeGraphData, parentId?: string): void {
const self = this;
// 如果没有父节点或找不到该节点是全量的更新直接重置data
if (!parentId || !self.findById(parentId)) {
self.changeData(data);
return;
}
const parentModel = self.findDataById(parentId) as NodeConfig;
const current = self.findById(data.id);
if (!parentModel.children) {
// 当 current 不存在时children 为空数组
parentModel.children = [];
}
// 如果不存在该节点,则添加
if (!current) {
parentModel.children.push(data);
} else {
const index = TreeGraph.indexOfChild(parentModel.children, data.id);
parentModel.children[index] = data;
}
self.changeData();
}
/**
*
* @param {string} id id
*/
public removeChild(id: string): void {
const self = this;
const node = self.findById(id);
if (!node) {
return;
}
const parent = node.get('parent');
if (parent && !parent.destroyed) {
const parentNode = self.findDataById(parent.get('id'));
const siblings = (parentNode && parentNode.children) || [];
const model: NodeConfig = node.getModel() as NodeConfig;
const index = TreeGraph.indexOfChild(siblings, model.id);
siblings.splice(index, 1);
}
self.changeData();
}
/**
* id获取对应的源数据
* @param {string} id id
* @param {TreeGraphData | undefined} parent
* @return {TreeGraphData}
*/
public findDataById(id: string, parent?: TreeGraphData | undefined): TreeGraphData | null {
const self = this;
if (!parent) {
parent = self.get('data') as TreeGraphData;
}
if (id === parent.id) {
return parent;
}
let result: TreeGraphData | null = null;
// eslint-disable-next-line consistent-return
each(parent.children || [], child => {
if (child.id === id) {
result = child;
return false;
}
result = self.findDataById(id, child);
if (result) {
return false;
}
});
return result;
}
/**
*
* @param {TreeGraphData} data
* @param {function} onFrame
*/
public layoutAnimate(
data: TreeGraphData,
onFrame?: (
item: Item,
ratio: number,
originAttrs?: ShapeStyle,
data?: TreeGraphData,
) => unknown,
): void {
const self = this;
const animateCfg = this.get('animateCfg');
self.emit('beforeanimate', { data });
// 如果边中没有指定锚点,但是本身有锚点控制,在动画过程中保持锚点不变
self.getEdges().forEach(edge => {
const model = edge.get('model');
if (!model.sourceAnchor) {
model.sourceAnchor = edge.get('sourceAnchorIndex');
}
});
this.get('canvas').animate(
(ratio: number) => {
traverseTree<TreeGraphData>(data, child => {
const node = self.findById(child.id);
// 只有当存在node的时候才执行
if (node) {
let origin = node.get('originAttrs');
const model = node.get('model');
if (!origin) {
origin = {
x: model.x,
y: model.y,
};
node.set('originAttrs', origin);
}
if (onFrame) {
const attrs = onFrame(node, ratio, origin, data);
node.set('model', Object.assign(model, attrs));
} else {
model.x = origin.x + (child.x! - origin.x) * ratio;
model.y = origin.y + (child.y! - origin.y) * ratio;
}
}
return true;
});
each(self.get('removeList'), node => {
const model = node.getModel();
const from = node.get('originAttrs');
const to = node.get('to');
model.x = from.x + (to.x - from.x) * ratio;
model.y = from.y + (to.y - from.y) * ratio;
});
self.refreshPositions();
},
{
duration: animateCfg.duration,
easing: animateCfg.ease,
callback: () => {
each(self.getNodes(), node => {
node.set('originAttrs', null);
});
each(self.get('removeList'), node => {
self.removeItem(node);
});
self.set('removeList', []);
if (animateCfg.callback) {
animateCfg.callback();
}
self.emit('afteranimate', { data });
},
delay: animateCfg.delay,
},
);
}
/**
*
*/
public stopLayoutAnimate(): void {
this.get('canvas').stopAnimate();
this.emit('layoutanimateend', { data: this.get('data') });
this.layoutAnimating = false;
}
/**
*
* @return {boolean}
*/
public isLayoutAnimating(): boolean {
return this.layoutAnimating;
}
/**
* data接口的数据渲染视图
*/
public render(): void {
const self = this;
const data: TreeGraphData = self.get('data');
if (!data) {
throw new Error('data must be defined first');
}
self.clear();
self.emit('beforerender');
self.layout(this.get('fitView'));
self.emit('afterrender');
}
/**
*
* @return {object} data
*/
public save(): TreeGraphData | GraphData {
return this.get('data');
}
}

View File

@ -0,0 +1,34 @@
import Behaviors from "./behavior";
import Graph from "./graph/graph";
import TreeGraph from "./graph/tree-graph";
import Shape, { Arrow, Marker } from "./shape";
import Global from "./global";
import Util from "./util";
const registerNode = Shape.registerNode;
const registerEdge = Shape.registerEdge;
const registerCombo = Shape.registerCombo;
const registerBehavior = Behaviors.registerBehavior;
export {
registerNode,
registerCombo,
Graph,
TreeGraph,
Util,
registerEdge,
registerBehavior
};
export default {
version: Global.version,
Graph,
TreeGraph,
Util,
registerNode: Shape.registerNode,
registerEdge: Shape.registerEdge,
registerCombo: Shape.registerCombo,
registerBehavior: Behaviors.registerBehavior,
Arrow,
Marker
};

View File

@ -0,0 +1,43 @@
import { Event as GraphEvent } from '@antv/g-base';
import { Canvas } from '@antv/g-canvas';
import { G6Event, IG6GraphEvent, IShapeBase, Item } from '../types';
import { IGraph } from './graph';
export interface IBehavior {
getEvents(): {
[key in G6Event]?: string;
};
getDefaultCfg?(): object;
shouldBegin?(e?: IG6GraphEvent): boolean;
shouldUpdate?(e?: IG6GraphEvent): boolean;
shouldEnd?(e?: IG6GraphEvent): boolean;
bind?(e: IGraph): void;
unbind?(e: IGraph): void;
}
export class G6GraphEvent extends GraphEvent implements IG6GraphEvent {
public item: Item;
public canvasX: number;
public canvasY: number;
public clientX: number;
public clientY: number;
public wheelDelta: number;
public detail: number;
public target!: IShapeBase & Canvas;
constructor(type: string, event: IG6GraphEvent) {
super(type, event);
this.item = event.item;
this.canvasX = event.canvasX;
this.canvasY = event.canvasY;
this.wheelDelta = event.wheelDelta;
this.detail = event.detail;
}
}

View File

@ -0,0 +1,642 @@
import EventEmitter from '@antv/event-emitter';
import { Point } from '@antv/g-base/lib/types';
import { IGroup } from '@antv/g-base/lib/interfaces';
import Graph from '../graph/graph';
import {
EdgeConfig,
GraphData,
Item,
ITEM_TYPE,
ModelConfig,
NodeConfig,
Padding,
ShapeStyle,
TreeGraphData,
LayoutConfig,
GraphOptions,
ModeOption,
ModeType,
ComboConfig,
GraphAnimateConfig,
StackData,
HullCfg,
WaterMarkerConfig,
IG6GraphEvent,
} from '../types';
import { IEdge, INode, ICombo } from './item';
import Hull from '../item/hull';
import Stack from '../algorithm/structs/stack';
export interface IGraph extends EventEmitter {
getDefaultCfg(): Partial<GraphOptions>;
get<T = any>(key: string): T;
set<T = any>(key: string | object, value?: T): Graph;
findById(id: string): Item;
translate(dx: number, dy: number): void;
zoom(ratio: number, center?: Point): void;
/**
* graph
* @return group
*/
getGroup(): IGroup;
/**
* graph DOM
* @return DOM
*/
getContainer(): HTMLElement;
/**
* graph
* @return minZoom
*/
getMinZoom(): number;
/**
* graph
* @return minZoom
*/
setMinZoom(ratio: number);
/**
* graph
* @param maxZoom
*/
getMaxZoom(): number;
/**
* graph
* @param maxZoom
*/
setMaxZoom(ratio: number);
/**
* graph
* @return width
*/
getWidth(): number;
/**
* graph
* @return height
*/
getHeight(): number;
/**
*
* @param {number} clientX x
* @param {number} clientY y
* @return {Point}
*/
getPointByClient(clientX: number, clientY: number): Point;
/**
*
* @param {number} x x坐标
* @param {number} y y坐标
* @return {object}
*/
getClientByPoint(x: number, y: number): Point;
/**
*
* @param {number} canvasX x
* @param {number} canvasY y
* @return {Point}
*/
getPointByCanvas(canvasX: number, canvasY: number): Point;
/**
*
* @param {number} x x
* @param {number} y y
* @return {Point}
*/
getCanvasByPoint(x: number, y: number): Point;
/**
* /
* @param {boolean} auto
*/
setAutoPaint(auto: boolean): void;
/**
*
* @param {Item} item
* @param {boolean} stack true
*/
showItem(item: Item | string, stack?: boolean): void;
/**
*
* @param {Item} item
* @param {boolean} stack true
*/
hideItem(item: Item | string, stack?: boolean): void;
/**
*
*/
paint(): void;
/**
*
*/
autoPaint(): void;
/**
*
* @param {Item} item id或元素实例
*/
refreshItem(item: Item | string): void;
/**
*
* @param {Item} item
* @param {boolean} animate
* @param {GraphAnimateConfig} animateCfg
*/
focusItem(item: Item | string, animate?: boolean, animateCfg?: GraphAnimateConfig): void;
/**
*
* @param {Padding} padding
*/
fitView(padding?: Padding): void;
/**
* bbox
*/
fitCenter(): void;
/**
*
* @param {number} toRatio
* @param {Point} center center的x, y坐标为中心缩放
*/
zoomTo(toRatio: number, center?: Point): void;
/**
*
* @param {Item} item id或元素实例
* @param {boolean} stack true
*/
removeItem(item: Item | string, stack?: boolean): void;
/**
*
* @param {Item} item id或元素实例
* @param {boolean} stack true
*/
remove(item: Item | string, stack?: boolean): void;
/**
*
* @param {string} type (node | edge)
* @param {ModelConfig} model
* @param {boolean} stack true
* @return {Item}
*/
addItem(type: ITEM_TYPE, model: ModelConfig, stack?: boolean): Item;
add(type: ITEM_TYPE, model: ModelConfig, stack?: boolean): Item;
/**
*
* @param {Item} item id或元素实例
* @param {EdgeConfig | NodeConfig} cfg
* @param {boolean} stack true
*/
updateItem(item: Item | string, cfg: Partial<NodeConfig> | EdgeConfig, stack?: boolean): void;
update(item: Item | string, cfg: Partial<NodeConfig> | EdgeConfig, stack?: boolean): void;
/**
* Combo
* @param {string | INode | ICombo} item Combo id
* @param {string | undefined} parentId combo idundefined combo
*/
updateComboTree(
item: string | INode | ICombo,
parentId?: string | undefined,
stack?: boolean,
): void;
/**
* combo
* @param {String | ICombo} item Combo item id
*/
uncombo(item: string | ICombo): void;
/**
* combo combo
* @param combo combo ID Combo
* @param elements Combo combo
*/
createCombo(combo: string | ComboConfig, elements: string[]): void;
/**
*
* @param {Item} item id或元素实例
* @param {string} state
* @param {boolean} value
*/
setItemState(item: Item | string, state: string, value: string | boolean): void;
/**
*
* @param {Item} item id或元素实例
* @param state
*/
priorityState(item: Item | string, state: string): void;
/**
*
* @param {GraphData} data
*/
data(data?: GraphData | TreeGraphData): void;
/**
*
*/
refresh(): void;
/**
* graph animateCfg
*/
positionsAnimate(): void;
/**
*
*/
refreshPositions(): void;
/**
* data接口的数据渲染视图
*/
render(): void;
/**
* item实例
*/
getNodes(): INode[];
/**
* item实例
*/
getEdges(): IEdge[];
/**
* combo
*/
getCombos(): ICombo[];
/**
*
*
* @param {(string | INode)} node ID
* @returns {INode[]}
* @memberof IGraph
*/
getNeighbors(node: string | INode, type?: 'source' | 'target' | undefined): INode[];
/**
* node
*
* @param {(string | INode)} node ID
* @param {('in' | 'out' | 'total' | 'all' | undefined)} in out total all
* @returns {Number | Object}
* @memberof IGraph
*/
getNodeDegree(
node: string | INode,
type?: 'in' | 'out' | 'total' | 'all' | undefined,
): Number | Object;
/**
* combo
* @param comboId Combo ID combo
*/
getComboChildren(combo: string | ICombo): { nodes: INode[]; combos: ICombo[] };
/**
*
* @return {number}
*/
getZoom(): number;
/**
*
*/
getCurrentMode(): string;
/**
*
* @param {string} mode
*/
setMode(mode: string): Graph;
isAnimating(): boolean;
stopAnimate(): void;
/**
*
* @param {string | ModeOption | ModeType[]} behaviors
* @param {string | string[]} modes
* @return {Graph} Graph
*/
addBehaviors(behaviors: string | ModeOption | ModeType[], modes: string | string[]): Graph;
/**
*
* @param {string | ModeOption | ModeType[]} behaviors
* @param {string | string[]} modes
* @return {Graph} Graph
*/
removeBehaviors(behaviors: string | ModeOption | ModeType[], modes: string | string[]): Graph;
/**
*
*/
clear(): Graph;
/**
*
* @Param {GraphData} data
*/
read(data: GraphData): void;
/**
*
* @param {GraphData | TreeGraphData} data
* @param {boolean} true
* @return {object} this
*/
changeData(data?: GraphData | TreeGraphData, stack?: boolean): Graph;
/**
*
* @return {GraphData} data
*/
save(): TreeGraphData | GraphData;
/**
*
* @param {number} width
* @param {number} height
* @return {Graph} this
*/
changeSize(width: number, height: number): Graph;
/**
*
* @param {string|Item} item id或元素实例
* @param {string | string[]} states
*/
clearItemStates(item: Item | string, states?: string | string[]): void;
/**
* keyShape
*
* graph.node(node => {
* return {
* {
* type: 'rect',
* label: node.id,
* style: { fill: '#666' },
* stateStyles: {
* selected: { fill: 'blue' },
* custom: { fill: 'green' }
* }
* }
* }
* });
* @param {function} nodeFn
*/
node(nodeFn: (config: NodeConfig) => Partial<NodeConfig>): void;
/**
*
* @param {function} edgeFn , node
*/
edge(edgeFn: (config: EdgeConfig) => Partial<EdgeConfig>): void;
/**
* combo
* @param comboFn combo
*/
combo(comboFn: (config: ComboConfig) => Partial<ComboConfig>): void;
/**
*
* @param {number} x
* @param {number} y
*/
moveTo(x: number, y: number): void;
/**
*
* @param {ITEM_TYPE} type (node | edge | group)
* @param {(item: T, index: number) => T} fn
* @return {T}
*/
find<T extends Item>(type: ITEM_TYPE, fn: (item: T, index?: number) => boolean): T | undefined;
/**
*
* @param {string} type (node|edge)
* @param {string} fn
* @return {array}
*/
findAll<T extends Item>(type: ITEM_TYPE, fn: (item: T, index?: number) => boolean): T[];
/**
*
* @param {string} type (node|edge)
* @param {string} state z状态
* @return {object}
*/
findAllByState<T extends Item>(type: ITEM_TYPE, state: string): T[];
/**
*
* @param {object} cfg
* cfg type String
* cfg type
*/
updateLayout(cfg: LayoutConfig): void;
/**
*
*/
layout(): void;
/**
* Combo
* @param comboId combo ID combo
*/
collapseCombo(combo: string | ICombo): void;
/**
* Combo
* @param combo combo ID combo
*/
expandCombo(combo: string | ICombo): void;
/**
* Combo
* @param comboId combo ID combo
*/
collapseExpandCombo(combo: string | ICombo): void;
/**
* bbox combos combos
*/
updateCombos(): void;
/**
* undo stack
*/
getUndoStack(): Stack;
/**
* redo stack
*/
getRedoStack(): Stack;
/**
* undo redo
*/
getStackData(): {
undoStack: StackData[];
redoStack: StackData[];
};
/**
* undo stack & redo stack
*/
clearStack(): void;
/**
*
* @param action
* @param data
* @param stackType
*/
pushStack(action?: string, data?: unknown, stackType?: 'redo' | 'undo'): void;
/**
* bbox combo combos combos
* @param combo combo
*/
updateCombo(combo: string | ICombo): void;
/**
*
*
* @param {boolean} cache 使
* @param {boolean} directed graph.directed
* @returns {Matrix}
* @memberof IGraph
*/
getAdjMatrix(cache: boolean, directed?: boolean): Number | Object;
/**
*
*
* @param {boolean} cache 使
* @param {boolean} directed graph.directed
* @returns {Matrix}
* @memberof IGraph
*/
getShortestPathMatrix(cache: boolean, directed?: boolean): Number | Object;
/**
*
* @param cfg HullCfg
*/
createHull(cfg: HullCfg): void;
/**
* graph
* @return {[key: string]: Hull} hullId hull
*/
getHulls(): Hull[];
/**
* hullId hull
* @return Hull
*/
getHullById(hullId: string): Hull;
/**
* hullId Hull
*/
removeHull(hull: Hull | string);
/**
*
*/
on(eventName: string, callback: (e: IG6GraphEvent) => void, once?: boolean): this;
/**
*
*/
destroy(): void;
}
export interface ITreeGraph extends IGraph {
/**
* id
* @param {TreeGraphData} data
* @param {string | Item} parent id
*/
addChild(data: TreeGraphData, parent: string | Item): void;
/**
*
* @param {TreeGraphData} data
* @param {string} parent id
*/
updateChild(data: TreeGraphData, parent?: string): void;
/**
*
* @param {string} id id
*/
removeChild(id: string): void;
/**
* id获取对应的源数据
* @param {string} id id
* @param {TreeGraphData | undefined} parent
* @return {TreeGraphData}
*/
findDataById(id: string, parent?: TreeGraphData | undefined): TreeGraphData | null;
/**
*
* @param {TreeGraphData} data
* @param {function} onFrame
*/
layoutAnimate(
data: TreeGraphData,
onFrame?: (
item: Item,
ratio: number,
originAttrs?: ShapeStyle,
data?: TreeGraphData,
) => unknown,
): void;
/**
*
*/
stopLayoutAnimate(): void;
/**
*
* @return {boolean}
*/
isLayoutAnimating(): boolean;
}

View File

@ -0,0 +1,367 @@
/* eslint @typescript-eslint/no-use-before-define: 0 */
import { IGroup } from '@antv/g-base/lib/interfaces';
import { Point } from '@antv/g-base/lib/types';
import { Group } from '@antv/g-canvas';
import {
IBBox,
IPoint,
IShapeBase,
Item,
ModelConfig,
NodeConfig,
EdgeConfig,
ModelStyle,
ShapeStyle,
Indexable,
ComboConfig,
ITEM_TYPE,
TreeGraphData
} from '../types';
// item 的配置项
export type IItemBaseConfig = Partial<{
/**
* id
*/
id: string;
/**
*
*/
type: 'item' | 'node' | 'edge' | 'combo' | 'vedge';
/**
* data model
*/
model: ModelConfig;
/**
* G Group
*/
group: IGroup;
/**
* is open animate
*/
animate: boolean;
/**
* visible - not group visible
*/
visible: boolean;
/**
* locked - lock node
*/
locked: boolean;
/**
* capture event
*/
event: boolean;
/**
* key shape to calculate item's bbox
*/
keyShape: IShapeBase;
/**
* item's states, such as selected or active
* @type Array
*/
states: string[];
/**
* Item
*/
styles: ModelStyle;
source: string | Item;
target: string | Item;
linkCenter: boolean;
}> &
Indexable<any>;
export interface IItemBase {
_cfg: IItemBaseConfig | null;
destroyed: boolean;
isItem(): boolean;
getShapeStyleByName(name?: string): ShapeStyle;
/**
*
* @return {Array}
*/
getStates(): string[];
/**
*
* @param {String} state
* @return {Boolean}
*/
hasState(state: string): boolean;
getStateStyle(state: string): ShapeStyle;
getOriginStyle(): ShapeStyle;
getCurrentStatesStyle(): ShapeStyle;
/**
* visible
* @internal graph 使
* @param {String} state
* @param {Boolean} value
*/
setState(state: string, value: string | boolean): void;
clearStates(states?: string | string[]): void;
/**
*
* @return {G.Group}
*/
getContainer(): Group;
/**
* 线
* @return {IShapeBase}
*/
getKeyShape(): IShapeBase;
/**
* / / Combo
* @return {Object}
*/
getModel(): NodeConfig | EdgeConfig | ComboConfig | TreeGraphData;
/**
*
* @return {string}
*/
getType(): ITEM_TYPE;
/**
* Item ID
*/
getID(): string;
getShapeCfg(model: ModelConfig): ModelConfig;
/**
*
* 1. item model
* 2.
*
* shape
*/
refresh(): void;
/**
* model
* @internal Graph 使 graph.update
* @param {Object} cfg
* @param {boolean} onlyMove node combo true
*/
update(cfg: ModelConfig, onlyMove?: boolean): void;
/**
*
*/
updateShape(): void;
/**
*
* @param {object} cfg
*/
updatePosition(cfg: Point): void;
/**
*
*/
draw(): void;
/**
* item item matrix
*/
getBBox(): IBBox;
/**
* item matrix
*/
getCanvasBBox(): IBBox;
/**
*
*/
toFront(): void;
/**
*
*/
toBack(): void;
/**
*
*/
show(): void;
/**
*
*/
hide(): void;
/**
*
* @param {Boolean} visible
*/
changeVisibility(visible: boolean): void;
/**
*
* @param {Boolean} enable
*/
enableCapture(enable: boolean): void;
isVisible(): boolean;
isOnlyMove(cfg: ModelConfig): boolean;
get<T = any>(key: string): T;
set<T = any>(key: string, value: T): void;
destroy(): void;
}
export interface IEdge extends IItemBase {
setSource(source: INode | ICombo): void;
setTarget(target: INode | ICombo): void;
getSource(): INode | ICombo;
getTarget(): INode | ICombo;
}
export interface INode extends IItemBase {
/**
*
* @return {Array}
*/
getEdges(): IEdge[];
/**
* target == this
* @return {Array}
*/
getInEdges(): IEdge[];
/**
* source == this
* @return {Array}
*/
getOutEdges(): IEdge[];
/**
*
* @param {Number} index
* @return {Object} {x,y}
*/
getLinkPointByAnchor(index: number): IPoint;
/**
*
* @param {Object} point
* @return {Object} {x,y}
*/
getLinkPoint(point: IPoint): IPoint | null;
/**
*
* @param {Edge} edge
*/
addEdge(edge: IEdge): void;
/**
*
* @param {Edge} edge
*/
removeEdge(edge: IEdge): void;
/**
*
* @return {array} anchorPoints {x,y,...cfg}
*/
getAnchorPoints(): IPoint[] | number[][];
hasLocked(): boolean;
lock(): void;
unlock(): void;
/**
*
*
* @returns {INode[]}
* @memberof INode
*/
getNeighbors(type?: 'source' | 'target' | undefined): INode[];
}
export interface ICombo extends INode {
/**
* Combo ComboNode Edge
*/
getChildren: () => { nodes: INode[]; combos: ICombo[] };
/**
* Combo
*/
getNodes: () => INode[];
/**
* Combo combo
*/
getCombos: () => INode[];
/**
* Combo combo
* @param item combo Item
* @return boolean true false
*/
addChild: (item: INode | ICombo) => boolean;
/**
* Combo combo
* @param combo Combo
* @return boolean true false
*/
addCombo: (combo: ICombo) => boolean;
/**
* Combo
* @param node
* @return boolean true false
*/
addNode: (node: string | INode) => boolean;
/**
* Combo
* @param item Combo Node
* @return boolean true false
*/
removeChild: (item: ICombo | INode) => boolean;
/**
* Combo combo
* @param combo Combo
* @return boolean true false
*/
removeCombo: (combo: ICombo) => boolean;
/**
* Combo
* @param node
* @return boolean true false
*/
removeNode: (node: string | INode) => boolean;
}

View File

@ -0,0 +1,91 @@
import { Group as GGroup } from '@antv/g-canvas';
import { IShape } from '@antv/g-canvas/lib/interfaces';
import { IPoint, Item, LabelStyle, ModelConfig, ShapeStyle } from '../types';
export type ILabelConfig = Partial<{
position: string;
offset: number;
refX: number;
refY: number;
autoRotate: boolean;
style: LabelStyle;
}>;
export type ShapeDefine = string | ((cfg: ModelConfig) => string);
export type ShapeOptions = Partial<{
options: ModelConfig;
/**
* circleellipsepolyline...
*/
type: string;
itemType: string;
shapeType: string;
labelPosition: string;
labelAutoRotate: boolean;
jsx: ShapeDefine;
[key: string]: any;
/**
*
*/
draw(cfg?: ModelConfig, group?: GGroup): IShape;
getCustomConfig(cfg: ModelConfig): ModelConfig;
drawShape(cfg?: ModelConfig, group?: GGroup): IShape;
drawLabel(cfg: ModelConfig, group: GGroup): IShape;
getLabelStyleByPosition(cfg: ModelConfig, labelCfg: ILabelConfig, group?: GGroup): LabelStyle;
getLabelStyle(cfg: ModelConfig, labelCfg: ILabelConfig, group: GGroup): LabelStyle;
/**
* 便
*/
afterDraw(cfg?: ModelConfig, group?: GGroup, rst?: IShape): void;
afterUpdate(cfg?: ModelConfig, item?: Item): void;
/**
*
*/
setState(name?: string, value?: string | boolean, item?: Item): void;
/**
*
* @param {Object} cfg
* @return {Array|null} , null
*/
getControlPoints(cfg: ModelConfig): IPoint[] | undefined;
/**
*
* @param {Object} cfg
* @return {Array|null} , null
*/
getAnchorPoints(cfg?: ModelConfig): number[][] | undefined;
// 如果没定义 update 方法,每次都调用 draw 方法
update(cfg: ModelConfig, item: Item): void;
// 获取节点的大小,只对节点起效
getSize: (cfg: ModelConfig) => number[];
// 获取路径
// getPath: (cfg?: ModelConfig) => Array<Array<string | number>>
// getPath: (points?: Point[]) => Array<Array<string | number>>
// getPath: (points: Point[], routeCfg) => Array<Array<string | number>> | string
// getPath: (...args: Point[] | object[] | ModelConfig[]) => Array<Array<string | number>> | string
// 获取文本对齐方式,只对边起效
_getTextAlign: (labelPosition: string, angle: number) => string;
/**
* @internal
* @param {Object} cfg
* @return {Object}
*/
getPathPoints: (cfg: ModelConfig) => ModelConfig;
// [key: string]: (...args: string[] | number[] | object[] | ModelConfig[]) => unknown
}>;

View File

@ -0,0 +1,247 @@
import { ICombo, INode, IItemBaseConfig } from '../interface/item';
import { Group } from '@antv/g-canvas';
import Node from './node';
import { ComboConfig, IBBox, IShapeBase } from '../types';
import Global from '../global';
import { getBBox } from '../util/graphic';
import { isNumber } from '@antv/util';
const CACHE_BBOX = 'bboxCache';
const CACHE_CANVAS_BBOX = 'bboxCanvasCache';
const CACHE_SIZE = 'sizeCache';
const CACHE_ANCHOR_POINTS = 'anchorPointsCache';
export default class Combo extends Node implements ICombo {
public getDefaultCfg() {
return {
type: 'combo',
nodes: [],
edges: [],
combos: [],
};
}
public getShapeCfg(model: ComboConfig): ComboConfig {
const styles = this.get('styles');
const bbox = this.get('bbox');
if (styles && bbox) {
// merge graph的item样式与数据模型中的样式
const newModel = model;
const size = {
r: Math.hypot(bbox.height, bbox.width) / 2 || Global.defaultCombo.size[0] / 2,
width: bbox.width || Global.defaultCombo.size[0],
height: bbox.height || Global.defaultCombo.size[1],
};
newModel.style = { ...styles, ...model.style, ...size };
const padding = model.padding || Global.defaultCombo.padding;
if (isNumber(padding)) {
size.r += padding;
size.width += padding * 2;
size.height += padding * 2;
} else {
size.r += padding[0];
size.width += padding[1] + padding[3] || padding[1] * 2;
size.height += padding[0] + padding[2] || padding[0] * 2;
}
this.set(CACHE_SIZE, size);
return newModel;
}
return model;
}
/**
* keyshape
*/
public calculateCanvasBBox(): IBBox {
const keyShape: IShapeBase = this.get('keyShape');
const group: Group = this.get('group');
// 因为 group 可能会移动,所以必须通过父元素计算才能计算出正确的包围盒
const bbox = getBBox(keyShape, group);
bbox.x = bbox.minX;
bbox.y = bbox.minY;
bbox.centerX = (bbox.minX + bbox.maxX) / 2;
bbox.centerY = (bbox.minY + bbox.maxY) / 2;
const cacheSize = this.get(CACHE_SIZE);
if (cacheSize) {
const type: string = keyShape.get('type');
if (type === 'circle') {
bbox.width = cacheSize.r * 2;
bbox.height = cacheSize.r * 2;
} else {
bbox.width = cacheSize.width;
bbox.height = cacheSize.height;
}
bbox.minX = bbox.centerX - bbox.width / 2;
bbox.minY = bbox.centerY - bbox.height / 2;
bbox.maxX = bbox.centerX + bbox.width / 2;
bbox.maxY = bbox.centerY + bbox.height / 2;
} else {
bbox.width = bbox.maxX - bbox.minX;
bbox.height = bbox.maxY - bbox.minY;
bbox.centerX = (bbox.minX + bbox.maxX) / 2;
bbox.centerY = (bbox.minY + bbox.maxY) / 2;
}
return bbox;
}
/**
* Combo ComboNode Edge
*/
public getChildren(): { nodes: INode[]; combos: ICombo[] } {
const self = this;
return {
nodes: self.getNodes(),
combos: self.getCombos(),
};
}
/**
* Combo
*/
getNodes(): INode[] {
const self = this;
return self.get('nodes');
}
/**
* Combo combo
*/
getCombos(): ICombo[] {
const self = this;
return self.get('combos');
}
/**
* Combo combo node
* @param item Combo
* @return boolean true false
*/
addChild(item: ICombo | INode): boolean {
const self = this;
const itemType = item.getType();
switch (itemType) {
case 'node':
self.addNode(item);
break;
case 'combo':
self.addCombo(item as ICombo);
break;
default:
console.warn('Only node or combo items are allowed to be added into a combo');
return false;
}
return true;
}
/**
* Combo combo
* @param combo Combo
* @return boolean true false
*/
addCombo(combo: ICombo): boolean {
const self = this;
self.get('combos').push(combo);
return true;
}
/**
* Combo
* @param node
* @return boolean true false
*/
addNode(node: string | INode): boolean {
const self = this;
self.get('nodes').push(node);
return true;
}
/**
* Combo combo node
* @param item Combo
* @return boolean true false
*/
removeChild(item: ICombo | INode): boolean {
const self = this;
const itemType = item.getType();
switch (itemType) {
case 'node':
self.removeNode(item);
break;
case 'combo':
self.removeCombo(item as ICombo);
break;
default:
console.warn('Only node or combo items are allowed to be added into a combo');
return false;
}
return true;
}
/**
* Combo combo
* @param combo Combo
* @return boolean true false
*/
removeCombo(combo: ICombo): boolean {
if (!combo) return;
const combos = this.getCombos();
const index = combos.indexOf(combo);
if (index > -1) {
combos.splice(index, 1);
return true;
}
return false;
}
/**
* Combo
* @param node
* @return boolean true false
*/
removeNode(node: INode): boolean {
if (!node) return;
const nodes = this.getNodes();
const index = nodes.indexOf(node);
if (index > -1) {
nodes.splice(index, 1);
return true;
}
return false;
}
public isOnlyMove(cfg?: any): boolean {
return false;
}
/**
* item item matrix
* @return {Object} x,y,width,height, centerX, centerY
*/
public getBBox(): IBBox {
this.set(CACHE_CANVAS_BBOX, null);
const bbox: IBBox = this.calculateCanvasBBox();
return bbox;
}
public clearCache() {
this.set(CACHE_BBOX, null); // 清理缓存的 bbox
this.set(CACHE_CANVAS_BBOX, null);
this.set(CACHE_ANCHOR_POINTS, null);
}
public destroy() {
if (!this.destroyed) {
const animate = this.get('animate');
const group: Group = this.get('group');
if (animate) {
group.stopAnimate();
}
this.clearCache();
this.set(CACHE_SIZE, null);
this.set('bbox', null);
group.remove();
(this._cfg as IItemBaseConfig | null) = null;
this.destroyed = true;
}
}
}

View File

@ -0,0 +1,242 @@
import { isString, isPlainObject, isNil, mix } from '@antv/util';
import { IEdge, INode, ICombo } from '../interface/item';
import { EdgeConfig, IPoint, NodeConfig, SourceTarget, Indexable } from '../types';
import Item from './item';
import Node from './node';
const END_MAP: Indexable<string> = { source: 'start', target: 'end' };
const ITEM_NAME_SUFFIX = 'Node'; // 端点的后缀,如 sourceNode, targetNode
const POINT_NAME_SUFFIX = 'Point'; // 起点或者结束点的后缀,如 startPoint, endPoint
const ANCHOR_NAME_SUFFIX = 'Anchor';
export default class Edge extends Item implements IEdge {
protected getDefaultCfg() {
return {
type: 'edge',
sourceNode: null,
targetNode: null,
startPoint: null,
endPoint: null,
linkCenter: false,
};
}
private setEnd(name: SourceTarget, value: INode) {
const pointName = END_MAP[name] + POINT_NAME_SUFFIX;
const itemName = name + ITEM_NAME_SUFFIX;
const preItem = this.get(itemName);
if (preItem && !preItem.destroyed) {
// 如果之前存在节点,则移除掉边
preItem.removeEdge(this);
}
if (isPlainObject(value)) {
// 如果设置成具体的点,则清理节点
this.set(pointName, value);
this.set(itemName, null);
} else {
value!.addEdge(this);
this.set(itemName, value);
this.set(pointName, null);
}
}
/**
*
* @param name source | target
* @param model
* @param controlPoints
*/
private getLinkPoint(name: SourceTarget, model: EdgeConfig, controlPoints: IPoint[]): IPoint {
const pointName = END_MAP[name] + POINT_NAME_SUFFIX;
const itemName = name + ITEM_NAME_SUFFIX;
let point = this.get(pointName);
if (!point) {
const item = this.get(itemName);
const anchorName = name + ANCHOR_NAME_SUFFIX;
const prePoint = this.getPrePoint(name, controlPoints);
const anchorIndex = model[anchorName];
if (!isNil(anchorIndex)) {
// 如果有锚点,则使用锚点索引获取连接点
point = item.getLinkPointByAnchor(anchorIndex);
}
// 如果锚点没有对应的点或者没有锚点,则直接计算连接点
point = point || item.getLinkPoint(prePoint);
if (!isNil(point.index)) {
this.set(`${name}AnchorIndex`, point.index);
}
}
return point;
}
/**
*
* @param name
* @param controlPoints
*/
private getPrePoint(name: SourceTarget, controlPoints: IPoint[]): NodeConfig | IPoint {
if (controlPoints && controlPoints.length) {
const index = name === 'source' ? 0 : controlPoints.length - 1;
return controlPoints[index];
}
const oppositeName = name === 'source' ? 'target' : 'source'; // 取另一个节点的位置
return this.getEndPoint(oppositeName);
}
/**
*
* @param name
*/
private getEndPoint(name: SourceTarget): NodeConfig | IPoint {
const itemName = name + ITEM_NAME_SUFFIX;
const pointName = END_MAP[name] + POINT_NAME_SUFFIX;
const item = this.get(itemName);
// 如果有端点,直接使用 model
if (item) {
return item.get('model');
} // 否则直接使用点
return this.get(pointName);
}
/**
*
* @param model
*/
private getControlPointsByCenter(model: EdgeConfig) {
const sourcePoint = this.getEndPoint('source');
const targetPoint = this.getEndPoint('target');
const shapeFactory = this.get('shapeFactory');
const type = model.type;
return shapeFactory.getControlPoints(type, {
startPoint: sourcePoint,
endPoint: targetPoint,
});
}
private getEndCenter(name: SourceTarget): IPoint {
const itemName = name + ITEM_NAME_SUFFIX;
const pointName = END_MAP[name] + POINT_NAME_SUFFIX;
const item = this.get(itemName);
// 如果有端点,直接使用 model
if (item) {
const bbox = item.getBBox();
return {
x: bbox.centerX,
y: bbox.centerY,
};
} // 否则直接使用点
return this.get(pointName);
}
protected init() {
super.init();
// 初始化两个端点
this.setSource(this.get('source'));
this.setTarget(this.get('target'));
}
public getShapeCfg(model: EdgeConfig): EdgeConfig {
const self = this;
const linkCenter: boolean = self.get('linkCenter'); // 如果连接到中心,忽视锚点、忽视控制点
const cfg = super.getShapeCfg(model) as EdgeConfig;
if (linkCenter) {
cfg.startPoint = self.getEndCenter('source');
cfg.endPoint = self.getEndCenter('target');
} else {
const controlPoints = cfg.controlPoints || self.getControlPointsByCenter(cfg);
cfg.startPoint = self.getLinkPoint('source', model, controlPoints);
cfg.endPoint = self.getLinkPoint('target', model, controlPoints);
}
cfg.sourceNode = self.get('sourceNode');
cfg.targetNode = self.get('targetNode');
return cfg;
}
/**
*
*/
public getModel(): EdgeConfig {
const out: EdgeConfig = this.get('model');
const sourceItem = this.get(`source${ITEM_NAME_SUFFIX}`);
const targetItem = this.get(`target${ITEM_NAME_SUFFIX}`);
if (sourceItem) {
delete out[`source${ITEM_NAME_SUFFIX}`];
} else {
out.source = this.get(`start${POINT_NAME_SUFFIX}`);
}
if (targetItem) {
delete out[`target${ITEM_NAME_SUFFIX}`];
} else {
out.target = this.get(`end${POINT_NAME_SUFFIX}`);
}
if (!isString(out.source) && !isPlainObject(out.source)) {
out.source = (out.source as any).getID();
}
if (!isString(out.target) && !isPlainObject(out.target)) {
out.target = (out.target as any).getID();
}
return out;
}
public setSource(source: INode | ICombo) {
this.setEnd('source', source);
this.set('source', source);
}
public setTarget(target: INode | ICombo) {
this.setEnd('target', target);
this.set('target', target);
}
public getSource(): INode | ICombo {
return this.get('source');
}
public getTarget(): INode | ICombo {
return this.get('target');
}
public updatePosition() { }
/**
* path
* @param {object} cfg
*/
public update(cfg: EdgeConfig, onlyMove: boolean = false) {
const model: EdgeConfig = this.get('model');
const oriVisible = model.visible;
const cfgVisible = cfg.visible;
if (oriVisible !== cfgVisible && cfgVisible !== undefined) this.changeVisibility(cfgVisible);
const styles = this.get('styles');
if (cfg.stateStyles) {
// 更新 item 时更新 this.get('styles') 中的值
const { stateStyles } = cfg;
mix(styles, stateStyles);
delete cfg.stateStyles;
}
Object.assign(model, cfg);
this.updateShape();
this.afterUpdate();
this.clearCache();
}
public destroy() {
const sourceItem: Node = this.get(`source${ITEM_NAME_SUFFIX}`);
const targetItem: Node = this.get(`target${ITEM_NAME_SUFFIX}`);
if (sourceItem && !sourceItem.destroyed) {
sourceItem.removeEdge(this);
}
if (targetItem && !targetItem.destroyed) {
targetItem.removeEdge(this);
}
super.destroy();
}
}

View File

@ -0,0 +1,277 @@
import { IGroup } from '@antv/g-base';
import { deepMix, isString } from '@antv/util';
import { parsePathString } from '@antv/path-util';
import { Item, BubblesetCfg, HullCfg } from '../types';
import { pathToPoints, getClosedSpline, roundedHull, paddedHull } from '../util/path';
import { isPolygonsIntersect } from '../util/math';
import { IGraph } from '../interface/graph';
import { genConvexHull } from '../shape/hull/convexHull';
import { genBubbleSet } from '../shape/hull/bubbleset';
/**
*
* convex hull()http://geomalgorithms.com/a10-_hull-1.html#Monotone%20Chain
* bubble: 使用 bubbleset算法refer: http://vialab.science.uoit.ca/wp-content/papercite-data/pdf/col2009c.pdf
* padding
*/
export default class Hull {
id: string;
graph: IGraph;
cfg: any;
path: any[][];
group: IGroup;
members: Item[];
nonMembers: Item[];
padding: number;
bubbleCfg: Partial<BubblesetCfg>;
type: string;
constructor(graph: IGraph, cfg: HullCfg) {
this.cfg = deepMix(this.getDefaultCfg(), cfg);
this.graph = graph;
this.id = this.cfg.id;
this.group = this.cfg.group;
this.members = this.cfg.members.map((item) => (isString(item) ? graph.findById(item) : item));
this.nonMembers = this.cfg.nonMembers.map((item) =>
isString(item) ? graph.findById(item) : item,
);
this.setPadding();
this.setType();
this.path = this.calcPath(this.members, this.nonMembers);
this.render();
}
public getDefaultCfg(): HullCfg {
return {
id: 'g6-hull',
type: 'round-convex', // 'round-convex' /'smooth-convex' / 'bubble'
members: [],
nonMembers: [],
style: {
fill: 'lightblue',
stroke: 'blue',
opacity: 0.2,
},
padding: 10,
};
}
setPadding() {
const nodeSize = this.members.length && this.members[0].getKeyShape().getCanvasBBox().width / 2;
this.padding = this.cfg.padding > 0 ? this.cfg.padding + nodeSize : 10 + nodeSize;
this.cfg.bubbleCfg = {
nodeR0: this.padding - nodeSize,
nodeR1: this.padding - nodeSize,
morphBuffer: this.padding - nodeSize,
};
}
setType() {
this.type = this.cfg.type;
if (this.members.length < 3) {
this.type = 'round-convex';
}
if (this.type !== 'round-convex' && this.type !== 'smooth-convex' && this.type !== 'bubble') {
console.warn(
'The hull type should be either round-convex, smooth-convex or bubble, round-convex is used by default.',
);
this.type = 'round-convex';
}
}
calcPath(members: Item[], nonMembers: Item[]) {
let contour, path, hull;
switch (this.type) {
case 'round-convex':
contour = genConvexHull(members);
hull = roundedHull(
contour.map((p) => [p.x, p.y]),
this.padding,
);
path = parsePathString(hull);
break;
case 'smooth-convex':
contour = genConvexHull(members);
hull = paddedHull(
contour.map((p) => [p.x, p.y]),
this.padding,
);
path = contour.length >= 2 && getClosedSpline(hull);
break;
case 'bubble':
contour = genBubbleSet(members, nonMembers, this.cfg.bubbleCfg);
path = contour.length >= 2 && getClosedSpline(contour);
break;
default:
}
return path;
}
render() {
this.group.addShape('path', {
attrs: {
path: this.path,
...this.cfg.style,
},
id: this.id,
name: this.cfg.id,
});
this.group.toBack();
}
/**
* hull的成员nonMembers中nonMembers中去掉
* @param item
* @return boolean true false
*/
public addMember(item: Item | string): boolean {
if (!item) return;
if (isString(item)) item = this.graph.findById(item);
this.members.push(item);
const index = this.nonMembers.indexOf(item);
if (index > -1) {
this.nonMembers.splice(index, 1);
}
this.updateData(this.members, this.nonMembers);
return true;
}
/**
* hull需要排除的节点members中members中去掉
* @param item
* @return boolean true false
*/
public addNonMember(item: Item | string): boolean {
if (!item) return;
if (isString(item)) item = this.graph.findById(item);
this.nonMembers.push(item);
const index = this.members.indexOf(item);
if (index > -1) {
this.members.splice(index, 1);
}
this.updateData(this.members, this.nonMembers);
return true;
}
/**
* hull中的成员
* @param node
* @return boolean true false
*/
public removeMember(item: Item | string): boolean {
if (!item) return;
if (isString(item)) item = this.graph.findById(item);
const index = this.members.indexOf(item);
if (index > -1) {
this.members.splice(index, 1);
this.updateData(this.members, this.nonMembers);
return true;
}
return false;
}
/**
* @param node
* @return boolean true false
*/
public removeNonMember(item: Item | string): boolean {
if (!item) return;
if (isString(item)) item = this.graph.findById(item);
const index = this.nonMembers.indexOf(item);
if (index > -1) {
this.nonMembers.splice(index, 1);
this.updateData(this.members, this.nonMembers);
return true;
}
return false;
}
public updateData(members: Item[] | string[], nonMembers: string[] | Item[]) {
this.group.findById(this.id).remove();
if (members)
this.members = (members as any[]).map((item) =>
isString(item) ? this.graph.findById(item) : item,
);
if (nonMembers)
this.nonMembers = (nonMembers as any[]).map((item) =>
isString(item) ? this.graph.findById(item) : item,
);
this.path = this.calcPath(this.members, this.nonMembers);
this.render();
}
public updateStyle(cfg: HullCfg['style']) {
const path = this.group.findById(this.id);
path.attr({
...cfg,
});
}
public updateCfg(cfg: Partial<HullCfg>) {
this.cfg = deepMix(this.cfg, cfg);
this.id = this.cfg.id;
this.group = this.cfg.group;
if (cfg.members) {
this.members = this.cfg.members.map((item) =>
isString(item) ? this.graph.findById(item) : item,
);
}
if (cfg.nonMembers) {
this.nonMembers = this.cfg.nonMembers.map((item) =>
isString(item) ? this.graph.findById(item) : item,
);
}
this.setPadding();
this.setType();
this.path = this.calcPath(this.members, this.nonMembers);
this.render();
}
/**
* hull内部
* @param item
*/
public contain(item: Item | string): boolean {
let nodeItem: Item;
if (isString(item)) {
nodeItem = this.graph.findById(item);
} else {
nodeItem = item;
}
let shapePoints;
const shape = nodeItem.getKeyShape();
if (nodeItem.get('type') === 'path') {
shapePoints = pathToPoints(shape.attr('path'));
} else {
const shapeBBox = shape.getCanvasBBox();
shapePoints = [
[shapeBBox.minX, shapeBBox.minY],
[shapeBBox.maxX, shapeBBox.minY],
[shapeBBox.maxX, shapeBBox.maxY],
[shapeBBox.minX, shapeBBox.maxY],
];
}
shapePoints = shapePoints.map((canvasPoint) => {
const point = this.graph.getPointByCanvas(canvasPoint[0], canvasPoint[1]);
return [point.x, point.y];
});
return isPolygonsIntersect(shapePoints, pathToPoints(this.path));
}
public destroy() {
this.group.remove();
this.cfg = null;
}
}

View File

@ -0,0 +1,790 @@
import { Group } from '@antv/g-canvas';
import {
each,
isNil,
isPlainObject,
isString,
isBoolean,
uniqueId,
mix,
deepMix,
isArray,
} from '@antv/util';
import { IItemBase, IItemBaseConfig } from '../interface/item';
import Shape from '../shape/shape';
import {
IBBox,
IPoint,
IShapeBase,
ModelConfig,
ShapeStyle,
Indexable,
NodeConfig,
EdgeConfig,
ComboConfig,
ITEM_TYPE,
} from '../types';
import { getBBox } from '../util/graphic';
import { translate } from '../util/math';
import { IGroup } from '@antv/g-base/lib/interfaces';
import Global from '../global';
const CACHE_BBOX = 'bboxCache';
const CACHE_CANVAS_BBOX = 'bboxCanvasCache';
const ARROWS = ['startArrow', 'endArrow'];
export default class ItemBase implements IItemBase {
public _cfg: IItemBaseConfig & {
[key: string]: unknown;
} = {};
public destroyed: boolean = false;
constructor(cfg: IItemBaseConfig) {
const defaultCfg: IItemBaseConfig = {
/**
* id
* @type {string}
*/
id: undefined,
/**
*
* @type {string}
*/
type: 'item',
/**
* data model
* @type {object}
*/
model: {} as ModelConfig,
/**
* g group
* @type {G.Group}
*/
group: undefined,
/**
* is open animate
* @type {boolean}
*/
animate: false,
/**
* visible - not group visible
* @type {boolean}
*/
visible: true,
/**
* locked - lock node
* @type {boolean}
*/
locked: false,
/**
* capture event
* @type {boolean}
*/
event: true,
/**
* key shape to calculate item's bbox
* @type object
*/
keyShape: undefined,
/**
* item's states, such as selected or active
* @type Array
*/
states: [],
};
this._cfg = Object.assign(defaultCfg, this.getDefaultCfg(), cfg);
const model = this.get('model');
let { id } = model;
const itemType = this.get('type');
if (!id) {
id = uniqueId(itemType);
this.get('model').id = id;
}
this.set('id', id);
const { group } = cfg;
if (group) {
group.set('item', this);
group.set('id', id);
}
this.init();
this.draw();
const shapeType = (model.shape as string) || (model.type as string) || (itemType === 'edge' ? 'line' : 'circle');
const shapeFactory = this.get('shapeFactory');
if (shapeFactory && shapeFactory[shapeType]) {
const { options } = shapeFactory[shapeType];
// merge the stateStyles from item and shape
if (options && options.stateStyles) {
let styles = this.get('styles') || model.stateStyles;
styles = deepMix({}, options.stateStyles, styles);
this.set('styles', styles);
}
}
}
/**
* keyshape
*/
private calculateBBox(): IBBox {
const keyShape: IShapeBase = this.get('keyShape');
const group: Group = this.get('group');
// 因为 group 可能会移动,所以必须通过父元素计算才能计算出正确的包围盒
const bbox = getBBox(keyShape, group);
bbox.x = bbox.minX;
bbox.y = bbox.minY;
bbox.width = bbox.maxX - bbox.minX;
bbox.height = bbox.maxY - bbox.minY;
bbox.centerX = (bbox.minX + bbox.maxX) / 2;
bbox.centerY = (bbox.minY + bbox.maxY) / 2;
return bbox;
}
/**
* keyshape
*/
public calculateCanvasBBox(): IBBox {
const keyShape: IShapeBase = this.get('keyShape');
const group: Group = this.get('group');
// 因为 group 可能会移动,所以必须通过父元素计算才能计算出正确的包围盒
const bbox = getBBox(keyShape, group);
bbox.x = bbox.minX;
bbox.y = bbox.minY;
bbox.width = bbox.maxX - bbox.minX;
bbox.height = bbox.maxY - bbox.minY;
bbox.centerX = (bbox.minX + bbox.maxX) / 2;
bbox.centerY = (bbox.minY + bbox.maxY) / 2;
return bbox;
}
/**
* draw shape
*/
private drawInner() {
const self = this;
const shapeFactory = self.get('shapeFactory');
const group: Group = self.get('group');
const model: ModelConfig = self.get('model');
group.clear();
const visible = model.visible;
if (visible !== undefined && !visible) self.changeVisibility(visible);
if (!shapeFactory) {
return;
}
self.updatePosition(model);
const cfg = self.getShapeCfg(model); // 可能会附加额外信息
const shapeType = (cfg.type as string);
const keyShape: IShapeBase = shapeFactory.draw(shapeType, cfg, group);
if (keyShape) {
self.set('keyShape', keyShape);
keyShape.set('isKeyShape', true);
keyShape.set('draggable', true);
}
this.setOriginStyle();
// 防止由于用户外部修改 model 中的 shape 导致 shape 不更新
this.set('currentShape', shapeType);
this.restoreStates(shapeFactory, shapeType!);
}
/**
*
* @param keyShape keyShape
* @param group Group
*/
private setOriginStyle(cfg?: ModelConfig) {
const originStyles = {};
const group: Group = this.get('group');
const children = group.get('children');
const keyShape: IShapeBase = this.getKeyShape();
const self = this;
const keyShapeName = keyShape.get('name');
if (!this.get('originStyle')) {
each(children, (child) => {
const name = child.get('name');
if (name && name !== keyShapeName) {
originStyles[name] = self.getShapeStyleByName(name);
} else {
const keyShapeStyle: ShapeStyle = self.getShapeStyleByName();
if (keyShapeStyle.path) delete keyShapeStyle.path;
if (keyShapeStyle.matrix) delete keyShapeStyle.matrix;
if (!keyShapeName) {
Object.assign(originStyles, keyShapeStyle);
} else {
originStyles[keyShapeName] = keyShapeStyle;
}
}
});
}
const itemType = this.get('type');
const model = this.getModel();
let shapeType = model.type;
if (!shapeType) {
switch (itemType) {
case 'edge':
shapeType = 'line';
break;
default:
shapeType = 'circle';
break;
}
}
let shapeFactory = Shape.getFactory(itemType)[shapeType];
if (!shapeFactory) shapeFactory = Shape.getFactory(itemType).getShape();
const shapeOptions = shapeFactory.getOptions ? shapeFactory.getOptions(model) : {};
const defaultStyle = shapeOptions.style || {};
const size = shapeOptions.size;
if (itemType === 'edge') {
if (!defaultStyle.lineWidth) defaultStyle.lineWidth = size || Global.defaultEdge.size;
} else {
if (!defaultStyle.r) defaultStyle.r = size / 2 || Global.defaultNode.size / 2;
if (!defaultStyle.width) defaultStyle.width = (isArray(size) ? size[0] : size) || Global.defaultNode.size / 2;
if (!defaultStyle.height) defaultStyle.height = (isArray(size) ? size[1] : size) || Global.defaultNode.size / 2;
}
if (!keyShapeName) {
Object.assign(originStyles, defaultStyle);
} else {
const styles: ShapeStyle = {};
for (const key in defaultStyle) {
const style = defaultStyle[key];
if (!isPlainObject(style) || ARROWS.includes(key)) styles[key] = style;
}
if (styles.path) delete styles.path;
if (styles.matrix) delete styles.matrix;
if (!originStyles[keyShapeName]) originStyles[keyShapeName] = styles;
else originStyles[keyShapeName] = Object.assign(styles, originStyles[keyShapeName]);
}
const drawOriginStyle = this.getOriginStyle();
let styles: ShapeStyle = {};
if (cfg) {
styles = deepMix({}, drawOriginStyle, originStyles, cfg.style, {
labelCfg: cfg.labelCfg,
});
} else {
styles = deepMix({}, drawOriginStyle, originStyles);
}
if (styles.path) delete styles.path;
if (styles.matrix) delete styles.matrix;
self.set('originStyle', styles);
}
/**
* restore shape states
* @param shapeFactory
* @param shapeType
*/
private restoreStates(shapeFactory: any, shapeType: string) {
const self = this;
const states: string[] = self.get('states');
each(states, (state) => {
shapeFactory.setState(shapeType, state, true, self);
});
}
protected init() {
const shapeFactory = Shape.getFactory(this.get('type'));
this.set('shapeFactory', shapeFactory);
}
/**
*
* @internal 使
* @param {String} key
* @return {object | string | number}
*/
public get<T = any>(key: string): T {
return this._cfg[key] as T;
}
/**
*
* @internal 使
* @param {String|Object} key
* @param {object | string | number} val
*/
public set(key: string | object, val?: unknown): void {
if (isPlainObject(key)) {
this._cfg = { ...this._cfg, ...key };
} else {
this._cfg[key] = val;
}
}
protected getDefaultCfg() {
return {};
}
/**
* / cache
*/
protected clearCache() {
this.set(CACHE_BBOX, null);
this.set(CACHE_CANVAS_BBOX, null);
}
/**
*
*/
protected beforeDraw() { }
/**
*
*/
protected afterDraw() { }
/**
*
*/
protected afterUpdate() { }
/**
* draw shape
*/
public draw() {
this.beforeDraw();
this.drawInner();
this.afterDraw();
}
public getShapeStyleByName(name?: string): ShapeStyle {
const group: Group = this.get('group');
let currentShape: IShapeBase = this.getKeyShape();
if (name) {
currentShape = group.find((element) => element.get('name') === name) as IShapeBase;
}
if (currentShape) {
const styles: ShapeStyle & Indexable<any> = {};
each(currentShape.attr(), (val, key) => {
// 修改 img 通过 updateItem 实现
if (key !== 'img') {
styles[key] = val;
}
});
return styles;
}
return {};
}
public getShapeCfg(model: ModelConfig): ModelConfig {
const styles = this.get('styles');
if (styles) {
// merge graph的item样式与数据模型中的样式
const newModel = model;
newModel.style = { ...styles, ...model.style };
return newModel;
}
return model;
}
/**
*
* @param state
*/
public getStateStyle(state: string) {
const styles = this.get('styles');
const stateStyle = styles && styles[state];
return stateStyle;
}
/**
* get keyshape style
*/
public getOriginStyle(): ShapeStyle {
return this.get('originStyle');
}
public getCurrentStatesStyle(): ShapeStyle {
const self = this;
let styles = {};
const states = self.getStates();
if (!states || !states.length) {
return this.getOriginStyle();
}
each(self.getStates(), (state) => {
styles = Object.assign(styles, self.getStateStyle(state));
});
return styles;
}
/**
* visible
* @internal graph 使
* @param {String} state
* @param {Boolean} value
*/
public setState(state: string, value: string | boolean) {
const states: string[] = this.get('states');
const shapeFactory = this.get('shapeFactory');
let stateName = state;
let filterStateName = state;
if (isString(value)) {
stateName = `${state}:${value}`;
filterStateName = `${state}:`;
}
let newStates = states;
if (isBoolean(value)) {
const index = states.indexOf(filterStateName);
if (value) {
if (index > -1) {
return;
}
states.push(stateName);
} else if (index > -1) {
states.splice(index, 1);
}
} else if (isString(value)) {
// 过滤掉 states 中 filterStateName 相关的状态
const filterStates = states.filter((name) => name.includes(filterStateName));
if (filterStates.length > 0) {
this.clearStates(filterStates);
}
newStates = newStates.filter((name) => !name.includes(filterStateName));
newStates.push(stateName);
this.set('states', newStates);
}
if (shapeFactory) {
const model: ModelConfig = this.get('model');
const type = model.type;
// 调用 shape/shape.ts 中的 setState
shapeFactory.setState(type, state, value, this);
}
}
/**
*
* @param states
*/
public clearStates(states?: string | string[]) {
const self = this;
const originStates = self.getStates();
const shapeFactory = self.get('shapeFactory');
const model: ModelConfig = self.get('model');
const shape = model.type;
if (!states) {
states = originStates;
}
if (isString(states)) {
states = [states];
}
const newStates = originStates.filter((state) => states.indexOf(state) === -1);
self.set('states', newStates);
states.forEach((state) => {
shapeFactory.setState(shape, state, false, self);
});
}
/**
*
* @return {G.Group}
*/
public getContainer(): Group {
return this.get('group');
}
/**
* 线
* @return {IShapeBase}
*/
public getKeyShape(): IShapeBase {
return this.get('keyShape');
}
/**
*
* @return {Object}
*/
public getModel(): NodeConfig | EdgeConfig | ComboConfig {
return this.get('model');
}
/**
*
* @return {string}
*/
public getType(): ITEM_TYPE {
return this.get('type');
}
/**
* Item ID
*/
public getID(): string {
return this.get('id');
}
/**
* Item
*/
public isItem(): boolean {
return true;
}
/**
*
* @return {Array}
*/
public getStates(): string[] {
return this.get('states');
}
/**
*
* @param {String} state
* @return {Boolean}
*/
public hasState(state: string): boolean {
const states = this.getStates();
return states.indexOf(state) >= 0;
}
/**
*
* 1. item model
* 2.
*
* shape
*/
public refresh() {
const model: ModelConfig = this.get('model');
// 更新元素位置
this.updatePosition(model);
// 更新元素内容,样式
this.updateShape();
// 做一些更新之后的操作
this.afterUpdate();
// 清除缓存
this.clearCache();
}
public isOnlyMove(cfg?: ModelConfig): boolean {
return false;
}
/**
* model
* @internal Graph 使 graph.update
* @param {Object} cfg
*/
public update(cfg: ModelConfig, onlyMove: boolean = false) {
const model: ModelConfig = this.get('model');
const oriVisible = model.visible;
const cfgVisible = cfg.visible;
if (oriVisible !== cfgVisible && cfgVisible !== undefined) this.changeVisibility(cfgVisible);
const originPosition: IPoint = { x: model.x!, y: model.y! };
cfg.x = isNaN(cfg.x) ? model.x : cfg.x;
cfg.y = isNaN(cfg.y) ? model.y : cfg.y;
const styles = this.get('styles');
if (cfg.stateStyles) {
// 更新 item 时更新 this.get('styles') 中的值
const { stateStyles } = cfg;
mix(styles, stateStyles);
delete cfg.stateStyles;
}
// 直接将更新合到原数据模型上,可以保证用户在外部修改源数据然后刷新时的样式符合期待。
Object.assign(model, cfg);
// isOnlyMove 仅用于node
// const onlyMove = this.isOnlyMove(cfg);
// 仅仅移动位置时,既不更新,也不重绘
if (onlyMove) {
this.updatePosition(cfg);
} else {
// 如果 x,y 有变化,先重置位置
if (originPosition.x !== cfg.x || originPosition.y !== cfg.y) {
this.updatePosition(cfg);
}
this.updateShape();
}
this.afterUpdate();
this.clearCache();
}
/**
*
*/
public updateShape() {
const shapeFactory = this.get('shapeFactory');
const model = this.get('model');
const shape = model.type;
// 判定是否允许更新
// 1. 注册的节点允许更新
// 2. 更新后的 shape 等于原先的 shape
if (shapeFactory.shouldUpdate(shape) && shape === this.get('currentShape')) {
const updateCfg = this.getShapeCfg(model);
shapeFactory.baseUpdate(shape, updateCfg, this);
} else {
// 如果不满足上面两种状态,重新绘制
this.draw();
}
// 更新完以后重新设置原始样式
this.setOriginStyle(model);
// 更新后重置节点状态
this.restoreStates(shapeFactory, shape);
}
/**
*
* @param {object} cfg
*/
public updatePosition(cfg: ModelConfig) {
const model: ModelConfig = this.get('model');
const x = isNil(cfg.x) ? model.x : cfg.x;
const y = isNil(cfg.y) ? model.y : cfg.y;
const group: Group = this.get('group');
if (isNil(x) || isNil(y)) {
return;
}
group.resetMatrix();
// G 4.0 element 中移除了矩阵相关方法详见https://www.yuque.com/antv/blog/kxzk9g#4rMMV
translate(group, { x: x!, y: y! });
model.x = x;
model.y = y;
this.clearCache(); // 位置更新后需要清除缓存
}
/**
* item item matrix
* @return {Object} x,y,width,height, centerX, centerY
*/
public getBBox(): IBBox {
// 计算 bbox 开销有些大,缓存
let bbox: IBBox = this.get(CACHE_BBOX);
if (!bbox) {
bbox = this.calculateBBox();
this.set(CACHE_BBOX, bbox);
}
return bbox;
}
/**
* item matrix
* @return {Object} x,y,width,height, centerX, centerY
*/
public getCanvasBBox(): IBBox {
// 计算 bbox 开销有些大,缓存
let bbox: IBBox = this.get(CACHE_CANVAS_BBOX);
if (!bbox) {
bbox = this.calculateCanvasBBox();
this.set(CACHE_CANVAS_BBOX, bbox);
}
return bbox;
}
/**
*
*/
public toFront() {
const group: IGroup = this.get('group');
group.toFront();
}
/**
*
*/
public toBack() {
const group: IGroup = this.get('group');
group.toBack();
}
/**
*
*/
public show() {
this.changeVisibility(true);
}
/**
*
*/
public hide() {
this.changeVisibility(false);
}
/**
*
* @param {Boolean} visible
*/
public changeVisibility(visible: boolean) {
const group: Group = this.get('group');
if (visible) {
group.show();
} else {
group.hide();
}
this.set('visible', visible);
}
/**
*
* @return {Boolean}
*/
public isVisible(): boolean {
return this.get('visible');
}
/**
*
* @param {Boolean} enable
*/
public enableCapture(enable: boolean) {
const group: Group = this.get('group');
if (group) {
group.set('capture', enable);
}
}
public destroy() {
if (!this.destroyed) {
const animate = this.get('animate');
const group: Group = this.get('group');
if (animate) {
group.stopAnimate();
}
this.clearCache();
group.remove();
(this._cfg as IItemBaseConfig | null) = null;
this.destroyed = true;
}
}
}

View File

@ -0,0 +1,258 @@
import { each, isNil, mix } from '@antv/util';
import { IEdge, INode } from '../interface/item';
import { IPoint, IShapeBase, NodeConfig } from '../types';
import {
distance,
getCircleIntersectByPoint,
getEllipseIntersectByPoint,
getRectIntersectByPoint,
} from '../util/math';
import Item from './item';
const CACHE_ANCHOR_POINTS = 'anchorPointsCache';
const CACHE_BBOX = 'bboxCache';
export default class Node extends Item implements INode {
public getNearestPoint(points: IPoint[], curPoint: IPoint): IPoint {
let index = 0;
let nearestPoint = points[0];
let minDistance = distance(points[0], curPoint);
for (let i = 0; i < points.length; i++) {
const point = points[i];
const dis = distance(point, curPoint);
if (dis < minDistance) {
nearestPoint = point;
minDistance = dis;
index = i;
}
}
nearestPoint.anchorIndex = index;
return nearestPoint;
}
public getDefaultCfg() {
return {
type: 'node',
edges: [],
};
}
/**
*
*/
public getEdges(): IEdge[] {
return this.get('edges');
}
/**
*
*/
public getInEdges(): IEdge[] {
const self = this;
return this.get('edges').filter((edge: IEdge) => edge.get('target') === self);
}
/**
*
*/
public getOutEdges(): IEdge[] {
const self = this;
return this.get('edges').filter((edge: IEdge) => edge.get('source') === self);
}
/**
*
*
* @returns {INode[]}
* @memberof Node
*/
public getNeighbors(type?: 'target' | 'source' | undefined): INode[] {
const edges = this.get('edges') as IEdge[];
if (type === 'target') {
// 当前节点为 source它所指向的目标节点
const neighhborsConverter = (edge: IEdge) => {
return edge.getSource() === this;
};
return edges.filter(neighhborsConverter).map((edge) => edge.getTarget());
}
if (type === 'source') {
// 当前节点为 target它所指向的源节点
const neighhborsConverter = (edge: IEdge) => {
return edge.getTarget() === this;
};
return edges.filter(neighhborsConverter).map((edge) => edge.getSource());
}
// 若未指定 type ,则返回所有邻居
const neighhborsConverter = (edge: IEdge) => {
return edge.getSource() === this ? edge.getTarget() : edge.getSource();
};
return edges.map(neighhborsConverter);
}
/**
*
* @param {Number} index
*/
public getLinkPointByAnchor(index: number): IPoint {
const anchorPoints = this.getAnchorPoints();
return anchorPoints[index];
}
/**
*
* @param point
*/
public getLinkPoint(point: IPoint): IPoint | null {
const keyShape: IShapeBase = this.get('keyShape');
const type: string = keyShape.get('type');
const itemType: string = this.get('type');
let centerX;
let centerY;
const bbox = this.getBBox();
if (itemType === 'combo') {
centerX = (bbox.maxX + bbox.minX) / 2;
centerY = (bbox.maxY + bbox.minY) / 2;
} else {
centerX = bbox.centerX;
centerY = bbox.centerY;
}
const anchorPoints = this.getAnchorPoints();
let intersectPoint: IPoint | null;
switch (type) {
case 'circle':
intersectPoint = getCircleIntersectByPoint(
{
x: centerX!,
y: centerY!,
r: bbox.width / 2,
},
point,
);
break;
case 'ellipse':
intersectPoint = getEllipseIntersectByPoint(
{
x: centerX!,
y: centerY!,
rx: bbox.width / 2,
ry: bbox.height / 2,
},
point,
);
break;
default:
intersectPoint = getRectIntersectByPoint(bbox, point);
}
let linkPoint = intersectPoint;
// 如果存在锚点,则使用交点计算最近的锚点
if (anchorPoints.length) {
if (!linkPoint) {
// 如果计算不出交点
linkPoint = point;
}
linkPoint = this.getNearestPoint(anchorPoints, linkPoint);
}
if (!linkPoint) {
// 如果最终依然没法找到锚点和连接点,直接返回中心点
linkPoint = { x: centerX, y: centerY } as IPoint;
}
return linkPoint;
}
/**
*
* @return {array} anchorPoints
*/
public getAnchorPoints(): IPoint[] {
let anchorPoints: IPoint[] = this.get(CACHE_ANCHOR_POINTS);
if (!anchorPoints) {
anchorPoints = [];
const shapeFactory = this.get('shapeFactory');
const bbox = this.getBBox();
const model: NodeConfig = this.get('model');
const shapeCfg = this.getShapeCfg(model);
const type = model.type;
const points = shapeFactory.getAnchorPoints(type, shapeCfg) || [];
each(points, (pointArr, index) => {
const point = mix(
{
x: bbox.minX + pointArr[0] * bbox.width,
y: bbox.minY + pointArr[1] * bbox.height,
},
pointArr[2],
{
index,
},
);
anchorPoints.push(point);
});
this.set(CACHE_ANCHOR_POINTS, anchorPoints);
}
return anchorPoints;
}
/**
* add edge
* @param edge Edge instance
*/
public addEdge(edge: IEdge) {
this.get('edges').push(edge);
}
/**
*
*/
public lock() {
this.set('locked', true);
}
/**
*
*/
public unlock() {
this.set('locked', false);
}
public hasLocked(): boolean {
return this.get('locked');
}
/**
*
* @param {Edge} edge
*/
public removeEdge(edge: IEdge) {
const edges = this.getEdges();
const index = edges.indexOf(edge);
if (index > -1) {
edges.splice(index, 1);
}
}
public clearCache() {
this.set(CACHE_BBOX, null); // 清理缓存的 bbox
this.set(CACHE_ANCHOR_POINTS, null);
}
/**
*
* @param cfg
*/
public isOnlyMove(cfg: NodeConfig): boolean {
if (!cfg) {
return false;
}
const existX = !isNil(cfg.x);
const existY = !isNil(cfg.y);
const keys = Object.keys(cfg);
// 仅有一个字段,包含 x 或者 包含 y
// 两个字段,同时有 x同时有 y
return (keys.length === 1 && (existX || existY)) || (keys.length === 2 && existX && existY);
}
}

View File

@ -0,0 +1,58 @@
export default {
triangle: (width: number = 10, length: number = 15, d: number = 0) => {
const begin = d * 2;
const path = `M ${begin},0 L ${begin + length},-${width / 2} L ${begin + length},${
width / 2
} Z`;
return path;
},
vee: (width: number = 15, length: number = 20, d: number = 0) => {
const begin = d * 2;
const path = `M ${begin},0 L ${begin + length},-${width / 2}
L ${begin + (2 * length) / 3},0 L ${begin + length},${width / 2} Z`;
return path;
},
circle: (r: number = 5, d: number = 0) => {
const begin = d * 2;
const path = `M ${begin}, 0
a ${r},${r} 0 1,0 ${r * 2},0
a ${r},${r} 0 1,0 ${-r * 2},0`;
return path;
},
rect: (width: number = 10, length: number = 10, d: number = 0) => {
const begin = d * 2;
const path = `M ${begin},${-width / 2}
L ${begin + length},${-width / 2}
L ${begin + length},${width / 2}
L ${begin},${width / 2} Z`;
return path;
},
diamond: (width: number = 15, length: number = 15, d: number = 0) => {
const begin = d * 2;
const path = `M ${begin},0
L ${begin + length / 2},${-width / 2}
L ${begin + length},0
L ${begin + length / 2},${width / 2} Z`;
return path;
},
triangleRect: (
tWidth: number = 15,
tLength: number = 15,
rWidth: number = 15,
rLength: number = 3,
gap: number = 5,
d: number = 0,
) => {
const begin = d * 2;
const rectBegin = begin + tLength + gap;
const path = `M ${begin},0 L ${begin + tLength},-${tWidth / 2} L ${begin + tLength},${
tWidth / 2
} Z
M ${rectBegin}, -${rWidth / 2}
L ${rectBegin + rLength} -${rWidth / 2}
L ${rectBegin + rLength} ${rWidth / 2}
L ${rectBegin} ${rWidth / 2}
Z`;
return path;
},
};

View File

@ -0,0 +1,163 @@
/**
* @fileOverview common combo shape
* @author shiwu.wyy@antfin.com
*/
import { Group as GGroup } from '@antv/g-canvas';
import { IShape } from '@antv/g-canvas/lib/interfaces';
import { isArray, isNil, clone } from '@antv/util';
import { ILabelConfig, ShapeOptions } from '../interface/shape';
import { Item, LabelStyle, NodeConfig, ModelConfig, ShapeStyle } from '../types';
import Global from '../global';
import Shape from './shape';
import { shapeBase } from './shapeBase';
const singleCombo: ShapeOptions = {
itemType: 'combo',
// 单个图形的类型
shapeType: 'single-combo',
/**
* Combo top
* top, bottom, left, right, center
* @type {String}
*/
labelPosition: 'top',
/**
* labelPosition center
* @type {Number}
*/
refX: Global.comboLabel.refX,
refY: Global.comboLabel.refY,
options: {
style: {
stroke: Global.defaultCombo.style.stroke,
fill: Global.defaultCombo.style.fill,
lineWidth: Global.defaultCombo.style.lineWidth
},
labelCfg: {
style: {
fill: Global.comboLabel.style.fill,
fontSize: Global.comboLabel.style.fontSize,
},
},
stateStyles: {
...Global.comboStateStyles
}
},
/**
* Combo
* @internal Combo [width, height]
* @param {Object} cfg Combo
* @return {Array}
*/
getSize(cfg: ModelConfig): number[] {
let size: number | number[] = clone(cfg.size || this.options!.size || Global.defaultCombo.size);
// size 是数组,若长度为 1则补长度为 2
if (isArray(size) && size.length === 1) {
size = [size[0], size[0]];
}
// size 为数字,则转换为数组
if (!isArray(size)) {
size = [size, size];
}
return size;
},
// 私有方法,不希望扩展的 Combo 复写这个方法
getLabelStyleByPosition(cfg: NodeConfig, labelCfg: ILabelConfig): LabelStyle {
const labelPosition = labelCfg.position || this.labelPosition;
const { style: cfgStyle } = cfg;
let padding: number | number[] = cfg.padding || this.options.padding;
if (isArray(padding)) padding = padding[0];
let { refX, refY } = labelCfg;
// 考虑 refX 和 refY = 0 的场景,不用用 labelCfg.refX || Global.nodeLabel.refX
if (isNil(refX)) {
refX = this.refX as number; // 不居中时的偏移量
}
if (isNil(refY)) {
refY = this.refY as number; // 不居中时的偏移量
}
const size = this.getSize!(cfg as ModelConfig);
const r = Math.max(cfgStyle.r, size[0] / 2) || size[0] / 2;
const dis = r + padding;
let style: any;
switch (labelPosition) {
case 'top':
style = {
x: 0,
y: -dis - (refY as number),
textBaseline: 'bottom', // 文本在图形的上方
textAlign: 'center',
};
break;
case 'bottom':
style = {
x: 0,
y: dis + (refY as number),
textBaseline: 'bottom',
textAlign: 'center',
};
break;
case 'left':
style = {
x: -dis + (refX as number),
y: 0,
textAlign: 'left',
};
break;
case 'center':
style = {
x: 0,
y: 0,
text: cfg!.label,
textAlign: 'center',
};
break;
default:
style = {
x: dis + (refX as number),
y: 0,
textAlign: 'right',
};
break;
}
style.text = cfg.label;
return style;
},
drawShape(cfg: NodeConfig, group: GGroup): IShape {
const { shapeType } = this; // || this.type都已经加了 shapeType
const style = this.getShapeStyle!(cfg);
const shape = group.addShape(shapeType, {
attrs: style,
draggable: true,
name: 'combo-shape',
});
return shape;
},
updateShape(cfg: NodeConfig, item: Item, keyShapeStyle: ShapeStyle) {
const keyShape = item.get('keyShape');
const animate = cfg.animate === undefined ? this.options.animate : cfg.animate;
if (animate && keyShape.animate) {
keyShape.animate(keyShapeStyle, {
duration: 200,
easing: 'easeLinear',
});
} else {
keyShape.attr({
...keyShapeStyle,
});
}
(this as any).updateLabel(cfg, item);
// special for some types of nodes
},
};
const singleComboDef = { ...shapeBase, ...singleCombo };
Shape.registerCombo('single-combo', singleComboDef);

View File

@ -0,0 +1,112 @@
import { Group as GGroup } from '@antv/g-canvas';
import { IShape } from '@antv/g-canvas/lib/interfaces';
import { Item, ComboConfig, ShapeStyle } from '../../types';
import Global from '../../global';
import Shape from '../shape';
import { ShapeOptions } from '../../interface/shape';
import { isNumber, clone, mix, isArray } from '@antv/util';
// 圆形 Combo
Shape.registerCombo(
'circle',
{
// 自定义节点时的配置
options: {
size: [Global.defaultCombo.size[0], Global.defaultCombo.size[0]],
padding: Global.defaultCombo.padding[0],
animate: true,
style: {
stroke: Global.defaultCombo.style.stroke,
fill: Global.defaultCombo.style.fill,
lineWidth: Global.defaultCombo.style.lineWidth,
},
labelCfg: {
style: {
fill: Global.comboLabel.style.fill,
fontSize: Global.comboLabel.style.fontSize,
},
refX: 0,
refY: 0,
},
stateStyles: {
...Global.comboStateStyles
}
},
shapeType: 'circle',
// 文本位置
labelPosition: 'top',
drawShape(cfg: ComboConfig, group: GGroup): IShape {
const style = this.getShapeStyle!(cfg);
delete style.height;
delete style.width;
const keyShape: IShape = group.addShape('circle', {
attrs: style,
className: 'circle-combo',
name: 'circle-combo',
draggable: true,
});
return keyShape;
},
/**
* Combo Combo 使
* @param {Object} cfg Combo
* @return {Object} Combo
*/
getShapeStyle(cfg: ComboConfig): ShapeStyle {
const { style: defaultStyle } = this.options as ComboConfig;
let padding: number | number[] = cfg.padding || this.options.padding;
if (isArray(padding)) padding = padding[0];
const strokeStyle: ShapeStyle = {
stroke: cfg.color,
};
// 如果设置了color则覆盖默认的stroke属性
const style = mix({}, defaultStyle, strokeStyle, cfg.style);
const size = (this as ShapeOptions).getSize!(cfg);
let r: number;
if (!isNumber(style.r) || isNaN(style.r)) r = size[0] / 2 || Global.defaultCombo.style.r;
else r = Math.max(style.r, size[0] / 2) || size[0] / 2;
style.r = r + padding;
const styles = {
x: 0,
y: 0,
...style,
};
if (cfg.style) cfg.style.r = r;
else {
cfg.style = { r };
}
return styles;
},
update(cfg: ComboConfig, item: Item) {
const size = (this as ShapeOptions).getSize!(cfg);
let padding: number | number[] = cfg.padding || this.options.padding;
if (isArray(padding)) padding = padding[0];
const cfgStyle = clone(cfg.style);
const r = Math.max(cfgStyle.r, size[0] / 2) || size[0] / 2;
cfgStyle.r = r + padding;
const itemCacheSize = item.get('sizeCache');
if (itemCacheSize) {
itemCacheSize.r = cfgStyle.r;
}
// 下面这些属性需要覆盖默认样式与目前样式,但若在 cfg 中有指定则应该被 cfg 的相应配置覆盖。
const strokeStyle = {
stroke: cfg.color,
};
// 与 getShapeStyle 不同在于update 时需要获取到当前的 style 进行融合。即新传入的配置项中没有涉及的属性,保留当前的配置。
const keyShape = item.get('keyShape');
const style = mix({}, keyShape.attr(), strokeStyle, cfgStyle);
if (cfg.style) cfg.style.r = r;
else {
cfg.style = { r };
}
(this as any).updateShape(cfg, item, style, true);
},
},
'single-combo',
);

View File

@ -0,0 +1,2 @@
import './circle';
import './rect';

View File

@ -0,0 +1,215 @@
import { Group as GGroup } from '@antv/g-canvas';
import { IShape } from '@antv/g-canvas/lib/interfaces';
import { mix, isNumber, clone, isNil } from '@antv/util';
import { LabelStyle, Item, ComboConfig, ShapeStyle } from '../../types';
import Global from '../../global';
import Shape from '../shape';
import { ILabelConfig, ShapeOptions } from '../../interface/shape';
Shape.registerCombo(
'rect',
{
// 自定义 Combo 时的配置
options: {
size: [40, 5],
padding: [25, 20, 15, 20],
animate: true,
style: {
radius: 0,
stroke: Global.defaultCombo.style.stroke,
fill: Global.defaultCombo.style.fill,
lineWidth: Global.defaultCombo.style.lineWidth
},
// 文本样式配置
labelCfg: {
style: {
fill: Global.comboLabel.style.fill,
fontSize: Global.comboLabel.style.fontSize,
},
},
// 连接点,默认为左右
anchorPoints: [
[0, 0.5],
[1, 0.5],
],
stateStyles: {
...Global.comboStateStyles
}
},
shapeType: 'rect',
labelPosition: 'top',
drawShape(cfg: ComboConfig, group: GGroup): IShape {
const style = this.getShapeStyle!(cfg);
const keyShape = group.addShape('rect', {
attrs: style,
className: 'rect-combo',
name: 'rect-combo',
draggable: true,
});
return keyShape;
},
// 私有方法,不希望扩展的 Combo 复写这个方法
getLabelStyleByPosition(cfg: ComboConfig, labelCfg: ILabelConfig): LabelStyle {
const labelPosition = labelCfg.position || this.labelPosition;
const { style: cfgStyle } = cfg;
let padding = cfg.padding || this.options.padding;
if (isNumber(padding)) padding = [padding, padding, padding, padding];
let { refX, refY } = labelCfg;
// 考虑 refX 和 refY = 0 的场景,不用用 labelCfg.refX || Global.nodeLabel.refY
if (isNil(refX)) {
refX = this.refX as number; // 不居中时的偏移量
}
if (isNil(refY)) {
refY = this.refY as number; // 不居中时的偏移量
}
const leftDis = cfgStyle.width / 2 + padding[3];
const topDis = cfgStyle.height / 2 + padding[0];
let style: any;
switch (labelPosition) {
case 'top':
style = {
x: 0 - leftDis + refX,
y: 0 - topDis + refY,
textBaseline: 'top', // 文本在图形的上方
textAlign: 'left',
};
break;
case 'bottom':
style = {
x: 0,
y: topDis + refY,
textBaseline: 'bottom',
textAlign: 'center',
};
break;
case 'left':
style = {
x: 0 - leftDis + refY,
y: 0,
textAlign: 'left',
};
break;
case 'center':
style = {
x: 0,
y: 0,
text: cfg!.label,
textAlign: 'center',
};
break;
default:
style = {
x: leftDis + refX,
y: 0,
textAlign: 'right',
};
break;
}
style.text = cfg.label;
return style;
},
/**
* 使
* @param {Object} cfg
* @return {Object}
*/
getShapeStyle(cfg: ComboConfig) {
const { style: defaultStyle } = this.options as ComboConfig;
let padding: number | number[] = cfg.padding || this.options.padding;
if (isNumber(padding)) padding = [padding, padding, padding, padding];
const strokeStyle: ShapeStyle = {
stroke: cfg.color,
};
// 如果设置了color则覆盖默认的stroke属性
const style = mix({}, defaultStyle, strokeStyle, cfg.style);
const size = (this as ShapeOptions).getSize!(cfg);
let width: number;
let height: number;
if (!isNumber(style.width) || isNaN(style.width))
width = size[0] || Global.defaultCombo.style.width;
else width = Math.max(style.width, size[0]) || size[0];
if (!isNumber(style.height) || isNaN(style.height))
height = size[1] || Global.defaultCombo.style.height;
else height = Math.max(style.height, size[1]) || size[1];
const x = -width / 2 - padding[3];
const y = -height / 2 - padding[0];
style.width = width + padding[1] + padding[3];
style.height = height + padding[0] + padding[2];
const styles = {
x,
y,
...style,
};
if (!cfg.style) {
cfg.style = {
width,
height,
};
} else {
cfg.style.width = width;
cfg.style.height = height;
}
return styles;
},
update(cfg: ComboConfig, item: Item) {
const size = (this as ShapeOptions).getSize!(cfg);
let padding: number | number[] = cfg.padding || this.options.padding;
if (isNumber(padding)) padding = [padding, padding, padding, padding];
const cfgStyle = clone(cfg.style);
const width = Math.max(cfgStyle.width, size[0]) || size[0];
const height = Math.max(cfgStyle.height, size[1]) || size[1];
cfgStyle.width = width + padding[1] + padding[3];
cfgStyle.height = height + padding[0] + padding[2];
const itemCacheSize = item.get('sizeCache');
if (itemCacheSize) {
itemCacheSize.width = cfgStyle.width;
itemCacheSize.height = cfgStyle.height;
}
cfgStyle.x = -width / 2 - padding[3];
cfgStyle.y = -height / 2 - padding[0];
// 下面这些属性需要覆盖默认样式与目前样式,但若在 cfg 中有指定则应该被 cfg 的相应配置覆盖。
const strokeStyle = {
stroke: cfg.color,
};
// 与 getShapeStyle 不同在于update 时需要获取到当前的 style 进行融合。即新传入的配置项中没有涉及的属性,保留当前的配置。
const keyShape = item.get('keyShape');
const style = mix({}, keyShape.attr(), strokeStyle, cfgStyle);
if (cfg.style) {
cfg.style.width = width;
cfg.style.height = height;
} else {
cfg.style = { width, height };
}
(this as any).updateShape(cfg, item, style, false);
},
updateShape(cfg: ComboConfig, item: Item, keyShapeStyle: object) {
const keyShape = item.get('keyShape');
const animate = cfg.animate === undefined ? this.options.animate : cfg.animate;
if (animate && keyShape.animate) {
keyShape.animate(keyShapeStyle, {
duration: 200,
easing: 'easeLinear',
});
} else {
keyShape.attr({
...keyShapeStyle,
});
}
(this as any).updateLabel(cfg, item);
},
},
'single-combo',
);

View File

@ -0,0 +1,653 @@
/**
* @fileOverview
* @description mixin
* @author dxq613@gmail.com
*/
import { Point } from '@antv/g-base/lib/types';
import { Group as GGroup } from '@antv/g-canvas';
import { IShape, IElement } from '@antv/g-canvas/lib/interfaces';
import { deepMix, mix, each, isNil, isNumber, isArray } from '@antv/util';
import { ILabelConfig, ShapeOptions } from '../interface/shape';
import { EdgeConfig, EdgeData, IPoint, LabelStyle, ShapeStyle, Item, ModelConfig } from '../types';
import { getLabelPosition, getLoopCfgs } from '../util/graphic';
import { distance, getCircleCenterByPoints } from '../util/math';
import { getControlPoint, getSpline } from '../util/path';
import Global from '../global';
import Shape from './shape';
import { shapeBase, CLS_LABEL_BG_SUFFIX } from './shapeBase';
import { Path } from '@antv/g-canvas/lib/shape';
const CLS_SHAPE = 'edge-shape';
// start,end 倒置center 不变
function revertAlign(labelPosition: string): string {
let textAlign = labelPosition;
if (labelPosition === 'start') {
textAlign = 'end';
} else if (labelPosition === 'end') {
textAlign = 'start';
}
return textAlign;
}
const singleEdge: ShapeOptions = {
itemType: 'edge',
/**
*
* @type {String}
*/
labelPosition: 'center', // start, end, center
/**
* x
* @type {Number}
*/
refX: 0,
/**
* y
* @type {Number}
*/
refY: 0,
/**
* 线 false
* @type {Boolean}
*/
labelAutoRotate: false,
// 自定义边时的配置
options: {
size: Global.defaultEdge.size,
style: {
x: 0,
y: 0,
stroke: Global.defaultEdge.style.stroke,
lineAppendWidth: Global.defaultEdge.style.lineAppendWidth
},
labelCfg: {
style: {
fill: Global.edgeLabel.style.fill,
fontSize: Global.edgeLabel.style.fontSize,
},
},
stateStyles: {
...Global.edgeStateStyles
}
},
/**
* path
* @internal
* @param {Array} points
* @return {Array} path
*/
getPath(points: Point[]): Array<Array<string | number>> {
const path: Array<Array<string | number>> = [];
each(points, (point: Point, index: number) => {
if (index === 0) {
path.push(['M', point.x, point.y]);
} else {
path.push(['L', point.x, point.y]);
}
});
return path;
},
getShapeStyle(cfg: EdgeConfig): ShapeStyle {
const { style: defaultStyle } = this.options as ModelConfig;
const strokeStyle: ShapeStyle = {
stroke: cfg.color,
};
// 如果设置了color则覆盖默认的stroke属性
const style: ShapeStyle = mix({}, defaultStyle, strokeStyle, cfg.style);
const size = cfg.size || Global.defaultEdge.size;
cfg = this.getPathPoints!(cfg);
const { startPoint, endPoint } = cfg;
const controlPoints = this.getControlPoints!(cfg);
let points = [startPoint]; // 添加起始点
// 添加控制点
if (controlPoints) {
points = points.concat(controlPoints);
}
// 添加结束点
points.push(endPoint);
const path = (this as any).getPath(points);
const styles = mix(
{},
Global.defaultEdge.style as ShapeStyle,
{
stroke: Global.defaultEdge.color,
lineWidth: size,
path,
} as ShapeStyle,
style,
);
return styles;
},
updateShapeStyle(cfg: EdgeConfig, item: Item) {
const group = item.getContainer();
const strokeStyle: ShapeStyle = {
stroke: cfg.color,
};
const shape =
group.find((element) => element.get('className') === 'edge-shape') || item.getKeyShape();
const { size } = cfg;
cfg = this.getPathPoints!(cfg);
const { startPoint, endPoint } = cfg;
const controlPoints = this.getControlPoints!(cfg); // || cfg.controlPoints;
let points = [startPoint]; // 添加起始点
// 添加控制点
if (controlPoints) {
points = points.concat(controlPoints);
}
// 添加结束点
points.push(endPoint);
const currentAttr = shape.attr();
const previousStyle = mix({}, strokeStyle, currentAttr, cfg.style);
const source = cfg.sourceNode;
const target = cfg.targetNode;
let routeCfg: { [key: string]: unknown } = { radius: previousStyle.radius };
if (!controlPoints) {
routeCfg = { source, target, offset: previousStyle.offset, radius: previousStyle.radius };
}
if (currentAttr.endArrow && previousStyle.endArrow === false) {
cfg.style.endArrow = {
path: ''
};
}
if (currentAttr.startArrow && previousStyle.startArrow === false) {
cfg.style.startArrow = {
path: ''
};
}
const path = (this as any).getPath(points, routeCfg);
const style = mix(
strokeStyle,
shape.attr(),
{
lineWidth: size,
path
},
cfg.style
);
if (shape) {
shape.attr(style);
}
},
getLabelStyleByPosition(cfg: EdgeConfig, labelCfg: ILabelConfig, group?: GGroup): LabelStyle {
const labelPosition = labelCfg.position || this.labelPosition; // 文本的位置用户可以传入
const style: LabelStyle = {};
const pathShape =
group && (group.find((element) => element.get('className') === CLS_SHAPE) as Path);
// 不对 pathShape 进行判空,如果线不存在,说明有问题了
let pointPercent;
if (labelPosition === 'start') {
pointPercent = 0;
} else if (labelPosition === 'end') {
pointPercent = 1;
} else {
pointPercent = 0.5;
}
// 偏移量
const offsetX = labelCfg.refX || (this.refX as number);
const offsetY = labelCfg.refY || (this.refY as number);
// 如果两个节点重叠线就变成了一个点这时候label的位置就是这个点 + 绝对偏移
if (cfg.startPoint!.x === cfg.endPoint!.x && cfg.startPoint!.y === cfg.endPoint!.y) {
style.x = cfg.startPoint!.x + offsetX;
style.y = cfg.startPoint!.y + offsetY;
style.text = cfg.label as string;
return style;
}
const autoRotate = isNil(labelCfg.autoRotate) ? this.labelAutoRotate : labelCfg.autoRotate;
const offsetStyle = getLabelPosition(
pathShape as Path,
pointPercent,
offsetX,
offsetY,
autoRotate as boolean,
);
style.x = offsetStyle.x;
style.y = offsetStyle.y;
style.rotate = offsetStyle.rotate;
style.textAlign = this._getTextAlign!(labelPosition as string, offsetStyle.angle as number);
style.text = cfg.label as string;
return style;
},
getLabelBgStyleByPosition(
label: IElement,
cfg: EdgeConfig,
labelCfg?: ILabelConfig,
group?: GGroup,
) {
if (!label) {
return {};
}
const bbox = label.getBBox();
const backgroundStyle = labelCfg.style && labelCfg.style.background;
if (!backgroundStyle) {
return {};
}
const { padding } = backgroundStyle;
const backgroundWidth = bbox.width + padding[1] + padding[3];
const backgroundHeight = bbox.height + padding[0] + padding[2];
const labelPosition = labelCfg.position || this.labelPosition;
const style = {
...backgroundStyle,
width: backgroundWidth,
height: backgroundHeight,
x: bbox.minX - padding[2],
y: bbox.minY - padding[0],
rotate: 0,
};
const autoRotate = isNil(labelCfg.autoRotate) ? this.labelAutoRotate : labelCfg.autoRotate;
const pathShape =
group && (group.find((element) => element.get('className') === CLS_SHAPE) as Path);
// 不对 pathShape 进行判空,如果线不存在,说明有问题了
let pointPercent;
if (labelPosition === 'start') {
pointPercent = 0;
} else if (labelPosition === 'end') {
pointPercent = 1;
} else {
pointPercent = 0.5;
}
// 偏移量
const offsetX = labelCfg.refX || (this.refX as number);
const offsetY = labelCfg.refY || (this.refY as number);
// // 如果两个节点重叠线就变成了一个点这时候label的位置就是这个点 + 绝对偏移
if (cfg.startPoint!.x === cfg.endPoint!.x && cfg.startPoint!.y === cfg.endPoint!.y) {
style.x = cfg.startPoint!.x + offsetX;
style.y = cfg.startPoint!.y + offsetY;
return style;
}
let offsetStyle = getLabelPosition(
pathShape,
pointPercent,
offsetX - backgroundWidth / 2,
offsetY + backgroundHeight / 2,
autoRotate,
);
const rad = offsetStyle.angle;
if (rad > (1 / 2) * Math.PI && rad < ((3 * 1) / 2) * Math.PI) {
offsetStyle = getLabelPosition(
pathShape,
pointPercent,
offsetX + backgroundWidth / 2,
offsetY + backgroundHeight / 2,
autoRotate,
);
}
if (autoRotate) {
style.x = offsetStyle.x;
style.y = offsetStyle.y;
}
style.rotate = offsetStyle.rotate;
return style;
},
// 获取文本对齐方式
_getTextAlign(labelPosition: string, angle: number): string {
let textAlign = 'center';
if (!angle) {
return labelPosition;
}
angle = angle % (Math.PI * 2); // 取模
if (labelPosition !== 'center') {
if (
(angle >= 0 && angle <= Math.PI / 2) ||
(angle >= (3 / 2) * Math.PI && angle < 2 * Math.PI)
) {
textAlign = labelPosition;
} else {
textAlign = revertAlign(labelPosition);
}
}
return textAlign;
},
/**
* @internal
* @param {Object} cfg
* @return {Array}
*/
getControlPoints(cfg: EdgeConfig): IPoint[] | undefined {
return cfg.controlPoints;
},
/**
* @internal
* @param {Object} cfg
* @return {Object}
*/
getPathPoints(cfg: EdgeConfig): EdgeConfig {
return cfg;
},
/**
*
* @override
* @param {Object} cfg
* @param {G.Group} group
* @return {IShape}
*/
drawShape(cfg: EdgeConfig, group: GGroup): IShape {
const shapeStyle = this.getShapeStyle!(cfg);
const shape = group.addShape('path', {
className: CLS_SHAPE,
name: CLS_SHAPE,
attrs: shapeStyle,
});
return shape;
},
drawLabel(cfg: EdgeConfig, group: GGroup): IShape {
const { labelCfg: defaultLabelCfg } = this.options as ModelConfig;
const labelCfg = deepMix({
fontFamily: typeof window !== 'undefined' ? window.getComputedStyle(document.body, null).getPropertyValue("font-family") || 'Arial, sans-serif' : 'Arial, sans-serif',
}, defaultLabelCfg, cfg.labelCfg);
const labelStyle = this.getLabelStyle!(cfg, labelCfg, group);
const rotate = labelStyle.rotate;
delete labelStyle.rotate;
const label = group.addShape('text', {
attrs: labelStyle,
name: 'text-shape',
});
if (rotate) {
label.rotateAtStart(rotate);
}
if (labelStyle.background) {
const rect = this.drawLabelBg(cfg, group, label);
const labelBgClassname = this.itemType + CLS_LABEL_BG_SUFFIX;
rect.set('classname', labelBgClassname);
label.toFront();
}
return label;
},
drawLabelBg(cfg: ModelConfig, group: GGroup, label: IElement) {
const { labelCfg: defaultLabelCfg } = this.options as ModelConfig;
const labelCfg = deepMix({}, defaultLabelCfg, cfg.labelCfg);
const labelStyle = this.getLabelStyle!(cfg, labelCfg, group);
const rotate = labelStyle.rotate;
const style = this.getLabelBgStyleByPosition(label, cfg, labelCfg, group);
delete style.rotate;
const rect = group.addShape('rect', { name: 'text-bg-shape', attrs: style });
if (rotate) rect.rotateAtStart(rotate);
return rect;
},
};
const singleEdgeDef = { ...shapeBase, ...singleEdge };
Shape.registerEdge('single-edge', singleEdgeDef);
// 直线, 不支持控制点
Shape.registerEdge(
'line',
{
// 控制点不生效
getControlPoints() {
return undefined;
},
},
'single-edge',
);
// 直线
Shape.registerEdge(
'spline',
{
getPath(points: Point[]): Array<Array<string | number>> {
const path = getSpline(points);
return path;
},
},
'single-edge',
);
Shape.registerEdge(
'arc',
{
curveOffset: 20,
clockwise: 1,
getControlPoints(cfg: EdgeConfig): IPoint[] {
const { startPoint, endPoint } = cfg;
const midPoint = {
x: (startPoint.x + endPoint.x) / 2,
y: (startPoint.y + endPoint.y) / 2,
};
let center;
let arcPoint;
// 根据给定点计算圆弧
if (cfg.controlPoints !== undefined) {
arcPoint = cfg.controlPoints[0];
center = getCircleCenterByPoints(startPoint, arcPoint, endPoint);
// 根据控制点和直线关系决定 clockwise值
if (startPoint.x <= endPoint.x && startPoint.y > endPoint.y) {
this.clockwise = center.x > arcPoint.x ? 0 : 1;
} else if (startPoint.x <= endPoint.x && startPoint.y < endPoint.y) {
this.clockwise = center.x > arcPoint.x ? 1 : 0;
} else if (startPoint.x > endPoint.x && startPoint.y <= endPoint.y) {
this.clockwise = center.y < arcPoint.y ? 0 : 1;
} else {
this.clockwise = center.y < arcPoint.y ? 1 : 0;
}
// 若给定点和两端点共线,无法生成圆弧,绘制直线
if (
(arcPoint.x - startPoint.x) / (arcPoint.y - startPoint.y) ===
(endPoint.x - startPoint.x) / (endPoint.y - startPoint.y)
) {
return [];
}
} else {
// 根据直线连线中点的的偏移计算圆弧
// 若用户给定偏移量则根据其计算,否则按照默认偏移值计算
if (cfg.curveOffset === undefined) {
cfg.curveOffset = this.curveOffset;
}
if (isArray(cfg.curveOffset)) {
cfg.curveOffset = cfg.curveOffset[0];
}
if (cfg.curveOffset < 0) {
this.clockwise = 0;
} else {
this.clockwise = 1;
}
const vec = {
x: endPoint.x - startPoint.x,
y: endPoint.y - startPoint.y,
};
const edgeAngle = Math.atan2(vec.y, vec.x);
arcPoint = {
x: cfg.curveOffset * Math.cos(-Math.PI / 2 + edgeAngle) + midPoint.x,
y: cfg.curveOffset * Math.sin(-Math.PI / 2 + edgeAngle) + midPoint.y,
};
center = getCircleCenterByPoints(startPoint, arcPoint, endPoint);
}
const radius = distance(startPoint, center);
const controlPoints = [{ x: radius, y: radius }];
return controlPoints;
},
getPath(points: Point[]): Array<Array<string | number>> {
const path: Array<Array<string | number>> = [];
path.push(['M', points[0].x, points[0].y]);
// 控制点与端点共线
if (points.length === 2) {
path.push(['L', points[1].x, points[1].y]);
} else {
path.push([
'A',
points[1].x,
points[1].y,
0,
0,
this.clockwise as number,
points[2].x,
points[2].y,
]);
}
return path;
},
},
'single-edge',
);
Shape.registerEdge(
'quadratic',
{
curvePosition: 0.5, // 弯曲的默认位置
curveOffset: -20, // 弯曲度,沿着 startPoint, endPoint 的垂直向量(顺时针)方向,距离线的距离,距离越大越弯曲
getControlPoints(cfg: EdgeConfig): IPoint[] {
let { controlPoints } = cfg; // 指定controlPoints
if (!controlPoints || !controlPoints.length) {
const { startPoint, endPoint } = cfg;
if (cfg.curveOffset === undefined) cfg.curveOffset = this.curveOffset;
if (cfg.curvePosition === undefined) cfg.curvePosition = this.curvePosition;
if (isArray(this.curveOffset)) cfg.curveOffset = cfg.curveOffset[0];
if (isArray(this.curvePosition)) cfg.curvePosition = cfg.curveOffset[0];
const innerPoint = getControlPoint(
startPoint as Point,
endPoint as Point,
cfg.curvePosition as number,
cfg.curveOffset as number,
);
controlPoints = [innerPoint];
}
return controlPoints;
},
getPath(points: Point[]): Array<Array<string | number>> {
const path = [];
path.push(['M', points[0].x, points[0].y]);
path.push(['Q', points[1].x, points[1].y, points[2].x, points[2].y]);
return path;
},
},
'single-edge',
);
Shape.registerEdge(
'cubic',
{
curvePosition: [1 / 2, 1 / 2],
curveOffset: [-20, 20],
getControlPoints(cfg: EdgeConfig): IPoint[] {
let { controlPoints } = cfg; // 指定controlPoints
if (cfg.curveOffset === undefined) cfg.curveOffset = this.curveOffset;
if (cfg.curvePosition === undefined) cfg.curvePosition = this.curvePosition;
if (isNumber(cfg.curveOffset)) cfg.curveOffset = [cfg.curveOffset, -cfg.curveOffset];
if (isNumber(cfg.curvePosition))
cfg.curvePosition = [cfg.curvePosition, 1 - cfg.curvePosition];
if (!controlPoints || !controlPoints.length || controlPoints.length < 2) {
const { startPoint, endPoint } = cfg;
const innerPoint1 = getControlPoint(
startPoint as Point,
endPoint as Point,
cfg.curvePosition[0],
cfg.curveOffset[0],
);
const innerPoint2 = getControlPoint(
startPoint as Point,
endPoint as Point,
cfg.curvePosition[1],
cfg.curveOffset[1],
);
controlPoints = [innerPoint1, innerPoint2];
}
return controlPoints;
},
getPath(points: Point[]): Array<Array<string | number>> {
const path = [];
path.push(['M', points[0].x, points[0].y]);
path.push([
'C',
points[1].x,
points[1].y,
points[2].x,
points[2].y,
points[3].x,
points[3].y,
]);
return path;
},
},
'single-edge',
);
// 垂直方向的三阶贝塞尔曲线,不再考虑用户外部传入的控制点
Shape.registerEdge(
'cubic-vertical',
{
curvePosition: [1 / 2, 1 / 2],
getControlPoints(cfg: EdgeConfig): IPoint[] {
const { startPoint, endPoint } = cfg;
if (cfg.curvePosition !== undefined) this.curvePosition = cfg.curvePosition;
if (isNumber(this.curvePosition))
this.curvePosition = [this.curvePosition, 1 - this.curvePosition];
const innerPoint1 = {
x: startPoint!.x,
y: (endPoint!.y - startPoint!.y) * (this as any).curvePosition[0] + startPoint!.y,
};
const innerPoint2 = {
x: endPoint!.x,
y: (endPoint!.y - startPoint!.y) * (this as any).curvePosition[1] + startPoint!.y,
};
const controlPoints = [innerPoint1, innerPoint2];
return controlPoints;
},
},
'cubic',
);
// 水平方向的三阶贝塞尔曲线,不再考虑用户外部传入的控制点
Shape.registerEdge(
'cubic-horizontal',
{
curvePosition: [1 / 2, 1 / 2],
getControlPoints(cfg: EdgeConfig): IPoint[] {
const { startPoint, endPoint } = cfg;
if (cfg.curvePosition !== undefined) this.curvePosition = cfg.curvePosition;
if (isNumber(this.curvePosition))
this.curvePosition = [this.curvePosition, 1 - this.curvePosition];
const innerPoint1 = {
x: (endPoint!.x - startPoint!.x) * (this as any).curvePosition[0] + startPoint!.x,
y: startPoint!.y,
};
const innerPoint2 = {
x: (endPoint!.x - startPoint!.x) * (this as any).curvePosition[1] + startPoint!.x,
y: endPoint!.y,
};
const controlPoints = [innerPoint1, innerPoint2];
return controlPoints;
},
},
'cubic',
);
Shape.registerEdge(
'loop',
{
getPathPoints(cfg: ModelConfig): EdgeData {
return getLoopCfgs(cfg as EdgeData);
},
getControlPoints(cfg: EdgeConfig): IPoint[] | undefined {
return cfg.controlPoints;
},
afterDraw(cfg: EdgeConfig) {
cfg.controlPoints = undefined;
},
afterUpdate(cfg: EdgeConfig) {
cfg.controlPoints = undefined;
},
},
'cubic',
);

View File

@ -0,0 +1 @@
import './polyline';

View File

@ -0,0 +1,538 @@
import { each } from '@antv/util';
import { INode, ICombo } from '../../interface/item';
import { Point } from '@antv/g-base/lib/types';
export interface PolyPoint {
x: number;
y: number;
id?: string;
}
export type PBBox = Partial<{
x: number;
y: number;
minX: number;
minY: number;
maxX: number;
maxY: number;
height: number;
width: number;
centerX: number;
centerY: number;
}>;
export const getBBoxFromPoint = (point: PolyPoint): PBBox => {
const { x, y } = point;
return {
x,
y,
centerX: x,
centerY: y,
minX: x,
minY: y,
maxX: x,
maxY: y,
height: 0,
width: 0,
};
};
export const getBBoxFromPoints = (points: PolyPoint[] = []): PBBox => {
const xs: number[] = [];
const ys: number[] = [];
points.forEach((p) => {
xs.push(p.x);
ys.push(p.y);
});
const minX = Math.min.apply(Math, xs);
const maxX = Math.max.apply(Math, xs);
const minY = Math.min.apply(Math, ys);
const maxY = Math.max.apply(Math, ys);
return {
centerX: (minX + maxX) / 2,
centerY: (minY + maxY) / 2,
maxX,
maxY,
minX,
minY,
height: maxY - minY,
width: maxX - minX,
};
};
export const isBBoxesOverlapping = (b1: PBBox, b2: PBBox) =>
Math.abs(b1.centerX - b2.centerX) * 2 < b1.width + b2.width &&
Math.abs(b1.centerY - b2.centerY) * 2 < b1.height + b2.height;
export const filterConnectPoints = (points: PolyPoint[]): PolyPoint[] => {
// pre-process: remove duplicated points
const result: any[] = [];
const pointsMap: any = {};
points.forEach((p) => {
const id = `${p.x}-${p.y}`;
p.id = id;
pointsMap[id] = p;
});
each(pointsMap, (p) => {
result.push(p);
});
return result;
};
export const simplifyPolyline = (points: PolyPoint[]): PolyPoint[] => {
points = filterConnectPoints(points);
return points;
};
export const getSimplePolyline = (sPoint: PolyPoint, tPoint: PolyPoint): PolyPoint[] => [
sPoint,
{ x: sPoint.x, y: tPoint.y },
tPoint,
];
export const getExpandedBBox = (bbox: any, offset: number): PBBox => {
if (bbox.width === 0 && bbox.height === 0) {
// when it is a point
return bbox;
}
return {
centerX: bbox.centerX,
centerY: bbox.centerY,
minX: bbox.minX - offset,
minY: bbox.minY - offset,
maxX: bbox.maxX + offset,
maxY: bbox.maxY + offset,
height: bbox.height + 2 * offset,
width: bbox.width + 2 * offset,
};
};
export const isHorizontalPort = (port: PolyPoint, bbox: PBBox): boolean => {
const dx = Math.abs(port.x - bbox.centerX);
const dy = Math.abs(port.y - bbox.centerY);
return dx / bbox.width > dy / bbox.height;
};
export const getExpandedBBoxPoint = (bbox: any, point: PolyPoint): PolyPoint => {
const isHorizontal = isHorizontalPort(point, bbox);
if (isHorizontal) {
return {
x: point.x > bbox.centerX ? bbox.maxX : bbox.minX,
y: point.y,
};
}
return {
x: point.x,
y: point.y > bbox.centerY ? bbox.maxY : bbox.minY,
};
};
/**
*
* @param b1
* @param b2
*/
export const mergeBBox = (b1: PBBox, b2: PBBox): PBBox => {
const minX = Math.min(b1.minX, b2.minX);
const minY = Math.min(b1.minY, b2.minY);
const maxX = Math.max(b1.maxX, b2.maxX);
const maxY = Math.max(b1.maxY, b2.maxY);
return {
centerX: (minX + maxX) / 2,
centerY: (minY + maxY) / 2,
minX,
minY,
maxX,
maxY,
height: maxY - minY,
width: maxX - minX,
};
};
export const getPointsFromBBox = (bbox: PBBox): PolyPoint[] => {
// anticlockwise
const { minX, minY, maxX, maxY } = bbox;
return [
{
x: minX,
y: minY,
},
{
x: maxX,
y: minY,
},
{
x: maxX,
y: maxY,
},
{
x: minX,
y: maxY,
},
];
};
export const isPointOutsideBBox = (point: PolyPoint, bbox: PBBox): boolean => {
const { x, y } = point;
return x < bbox.minX || x > bbox.maxX || y < bbox.minY || y > bbox.maxY;
};
export const getBBoxXCrossPoints = (bbox: PBBox, x: number): PolyPoint[] => {
if (x < bbox.minX || x > bbox.maxX) {
return [];
}
return [
{
x,
y: bbox.minY,
},
{
x,
y: bbox.maxY,
},
];
};
export const getBBoxYCrossPoints = (bbox: PBBox, y: number): PolyPoint[] => {
if (y < bbox.minY || y > bbox.maxY) {
return [];
}
return [
{
x: bbox.minX,
y,
},
{
x: bbox.maxX,
y,
},
];
};
export const getBBoxCrossPointsByPoint = (bbox: PBBox, point: PolyPoint): PolyPoint[] =>
getBBoxXCrossPoints(bbox, point.x).concat(getBBoxYCrossPoints(bbox, point.y));
/**
*
*/
export const distance = (p1: PolyPoint, p2: PolyPoint): number =>
Math.abs(p1.x - p2.x) + Math.abs(p1.y - p2.y);
/**
* points x p -2y
* points 线 / 线
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
export const _costByPoints = (p: PolyPoint, points: PolyPoint[]): number => {
const offset = -2;
let result = 0;
points.forEach((point) => {
if (point) {
if (p.x === point.x) {
result += offset;
}
if (p.y === point.y) {
result += offset;
}
}
});
return result;
};
/**
* ps p pt
*/
export const heuristicCostEstimate = (
p: PolyPoint,
ps: PolyPoint,
pt: PolyPoint,
source?: PolyPoint,
target?: PolyPoint,
): number => distance(p, ps) + distance(p, pt) + _costByPoints(p, [ps, pt, source!, target!]);
export const reconstructPath = (
pathPoints: PolyPoint[],
pointById: any,
cameFrom: any,
currentId: string,
iterator: number = 0,
) => {
pathPoints.unshift(pointById[currentId]);
if (cameFrom[currentId] && cameFrom[currentId] !== currentId && iterator <= 100) {
reconstructPath(pathPoints, pointById, cameFrom, cameFrom[currentId], iterator + 1);
}
};
/**
* arr item
*/
export const removeFrom = (arr: PolyPoint[], item: PolyPoint) => {
const index = arr.indexOf(item);
if (index > -1) {
arr.splice(index, 1);
}
};
export const isSegmentsIntersected = (
p0: PolyPoint,
p1: PolyPoint,
p2: PolyPoint,
p3: PolyPoint,
): boolean => {
const s1X = p1.x - p0.x;
const s1Y = p1.y - p0.y;
const s2X = p3.x - p2.x;
const s2Y = p3.y - p2.y;
const s = (-s1Y * (p0.x - p2.x) + s1X * (p0.y - p2.y)) / (-s2X * s1Y + s1X * s2Y);
const t = (s2X * (p0.y - p2.y) - s2Y * (p0.x - p2.x)) / (-s2X * s1Y + s1X * s2Y);
return s >= 0 && s <= 1 && t >= 0 && t <= 1;
};
export const isSegmentCrossingBBox = (p1: PolyPoint, p2: PolyPoint, bbox: PBBox): boolean => {
if (bbox.width === 0 && bbox.height === 0) {
return false;
}
const [pa, pb, pc, pd] = getPointsFromBBox(bbox);
return (
isSegmentsIntersected(p1, p2, pa, pb) ||
isSegmentsIntersected(p1, p2, pa, pd) ||
isSegmentsIntersected(p1, p2, pb, pc) ||
isSegmentsIntersected(p1, p2, pc, pd)
);
};
/**
* points x y point x y point 线 bbox1 bbox2
*/
export const getNeighborPoints = (
points: PolyPoint[],
point: PolyPoint,
bbox1: PBBox,
bbox2: PBBox,
): PolyPoint[] => {
const neighbors: Point[] = [];
points.forEach((p) => {
if (p !== point) {
if (p.x === point.x || p.y === point.y) {
if (!isSegmentCrossingBBox(p, point, bbox1) && !isSegmentCrossingBBox(p, point, bbox2)) {
neighbors.push(p);
}
}
}
});
return filterConnectPoints(neighbors);
};
export const pathFinder = (
points: PolyPoint[],
start: PolyPoint,
goal: any,
sBBox: PBBox,
tBBox: PBBox,
os: any,
ot: any,
): PolyPoint[] => {
// A-Star Algorithm
const closedSet: any = [];
const openSet = [start];
const cameFrom: {
[key: string]: any;
} = {};
const gScore: {
[key: string]: number;
} = {}; // all default values are Infinity
const fScore: {
[key: string]: number;
} = {}; // all default values are Infinity
gScore[start.id] = 0;
fScore[start.id] = heuristicCostEstimate(start, goal, start);
const pointById: {
[key: string]: PolyPoint;
} = {};
points.forEach((p) => {
pointById[p.id] = p;
});
while (openSet.length) {
let current: any;
let lowestFScore = Infinity;
// 找到 openSet 中 fScore 最小的点
openSet.forEach((p: any) => {
if (fScore[p.id] < lowestFScore) {
lowestFScore = fScore[p.id];
current = p;
}
});
// 若 openSet 中 fScore 最小的点就是终点
if (current === goal) {
// ending condition
const pathPoints: any = [];
reconstructPath(pathPoints, pointById, cameFrom, goal.id);
return pathPoints;
}
removeFrom(openSet, current);
closedSet.push(current);
getNeighborPoints(points, current, sBBox, tBBox).forEach((neighbor) => {
if (closedSet.indexOf(neighbor) !== -1) {
return;
}
if (openSet.indexOf(neighbor) === -1) {
openSet.push(neighbor);
}
const tentativeGScore = fScore[current.id] + distance(current, neighbor); // + distance(neighbor, goal);
if (gScore[neighbor.id] && tentativeGScore >= gScore[neighbor.id]) {
return;
}
cameFrom[neighbor.id] = current.id;
gScore[neighbor.id] = tentativeGScore;
fScore[neighbor.id] =
gScore[neighbor.id] + heuristicCostEstimate(neighbor, goal, start, os, ot);
});
}
// throw new Error('Cannot find path');
return [start, goal];
};
export const isBending = (p0: PolyPoint, p1: PolyPoint, p2: PolyPoint): boolean =>
!((p0.x === p1.x && p1.x === p2.x) || (p0.y === p1.y && p1.y === p2.y));
export const getBorderRadiusPoints = (
p0: PolyPoint,
p1: PolyPoint,
p2: PolyPoint,
r: number,
): PolyPoint[] => {
const d0 = distance(p0, p1);
const d1 = distance(p2, p1);
if (d0 < r) {
r = d0;
}
if (d1 < r) {
r = d1;
}
const ps = {
x: p1.x - (r / d0) * (p1.x - p0.x),
y: p1.y - (r / d0) * (p1.y - p0.y),
};
const pt = {
x: p1.x - (r / d1) * (p1.x - p2.x),
y: p1.y - (r / d1) * (p1.y - p2.y),
};
return [ps, pt];
};
export const getPathWithBorderRadiusByPolyline = (
points: PolyPoint[],
borderRadius: number,
): string => {
// TODO
const pathSegments: string[] = [];
const startPoint = points[0];
pathSegments.push(`M${startPoint.x} ${startPoint.y}`);
points.forEach((p, i) => {
const p1 = points[i + 1];
const p2 = points[i + 2];
if (p1 && p2) {
if (isBending(p, p1, p2)) {
const [ps, pt] = getBorderRadiusPoints(p, p1, p2, borderRadius);
pathSegments.push(`L${ps.x} ${ps.y}`);
pathSegments.push(`Q${p1.x} ${p1.y} ${pt.x} ${pt.y}`);
pathSegments.push(`L${pt.x} ${pt.y}`);
} else {
pathSegments.push(`L${p1.x} ${p1.y}`);
}
} else if (p1) {
pathSegments.push(`L${p1.x} ${p1.y}`);
}
});
return pathSegments.join('');
};
export const getPolylinePoints = (
start: PolyPoint,
end: PolyPoint,
sNode: INode | ICombo,
tNode: INode | ICombo,
offset: number,
): PolyPoint[] => {
let sBBox: PBBox, tBBox: PBBox;
if (!sNode || !sNode.getType()) {
sBBox = getBBoxFromPoint(start);
} else if (sNode.getType() === 'combo') {
const sNodeKeyShape = sNode.getKeyShape();
sBBox = sNodeKeyShape.getCanvasBBox() || (getBBoxFromPoint(start) as PBBox);
sBBox.centerX = (sBBox.minX + sBBox.maxX) / 2;
sBBox.centerY = (sBBox.minY + sBBox.maxY) / 2;
} else {
sBBox = sNode.getBBox();
}
if (!tNode || !tNode.getType()) {
tBBox = getBBoxFromPoint(end);
} else if (tNode.getType() === 'combo') {
const tNodeKeyShape = tNode.getKeyShape();
tBBox = tNodeKeyShape.getCanvasBBox() || (getBBoxFromPoint(end) as PBBox);
tBBox.centerX = (tBBox.minX + tBBox.maxX) / 2;
tBBox.centerY = (tBBox.minY + tBBox.maxY) / 2;
} else {
tBBox = tNode && tNode.getBBox();
}
if (isBBoxesOverlapping(sBBox, tBBox)) {
// source and target nodes are overlapping
return simplifyPolyline(getSimplePolyline(start, end));
}
const sxBBox = getExpandedBBox(sBBox, offset);
const txBBox = getExpandedBBox(tBBox, offset);
if (isBBoxesOverlapping(sxBBox, txBBox)) {
// the expanded bounding boxes of source and target nodes are overlapping
return simplifyPolyline(getSimplePolyline(start, end));
}
const sPoint = getExpandedBBoxPoint(sxBBox, start);
const tPoint = getExpandedBBoxPoint(txBBox, end);
const lineBBox = getBBoxFromPoints([sPoint, tPoint]);
const outerBBox = mergeBBox(sxBBox, txBBox);
const sMixBBox = mergeBBox(sxBBox, lineBBox);
const tMixBBox = mergeBBox(txBBox, lineBBox);
let connectPoints: any = [];
connectPoints = connectPoints.concat(
getPointsFromBBox(sMixBBox), // .filter(p => !isPointIntersectBBox(p, txBBox))
);
connectPoints = connectPoints.concat(
getPointsFromBBox(tMixBBox), // .filter(p => !isPointIntersectBBox(p, sxBBox))
);
const centerPoint = {
x: (start.x + end.x) / 2,
y: (start.y + end.y) / 2,
};
[lineBBox, sMixBBox, tMixBBox].forEach((bbox: PBBox) => {
connectPoints = connectPoints.concat(
getBBoxCrossPointsByPoint(bbox, centerPoint).filter(
(p) => isPointOutsideBBox(p, sxBBox) && isPointOutsideBBox(p, txBBox),
),
);
});
[
{
x: sPoint.x,
y: tPoint.y,
},
{
x: tPoint.x,
y: sPoint.y,
},
].forEach((p) => {
// impossible!!
if (
isPointOutsideBBox(p, sxBBox) &&
isPointOutsideBBox(p, txBBox) // &&
// isPointInsideBBox(p, sMixBBox) && isPointInsideBBox(p, tMixBBox)
) {
connectPoints.push(p);
}
});
connectPoints.unshift(sPoint);
connectPoints.push(tPoint);
// filter out dulplicated points in connectPoints
connectPoints = filterConnectPoints(connectPoints); // , sxBBox, txBBox, outerBBox
const pathPoints = pathFinder(connectPoints, sPoint, tPoint, sBBox, tBBox, start, end);
pathPoints.unshift(start);
pathPoints.push(end);
return simplifyPolyline(pathPoints);
};

View File

@ -0,0 +1,198 @@
import { Point } from '@antv/g-base/lib/types';
import { Group } from '@antv/g-canvas';
import { mix, each, isArray, isString } from '@antv/util';
import { ShapeStyle, EdgeConfig, Item } from '../../types';
import { pointsToPolygon } from '../../util/path';
import Global from '../../global';
import Shape from '../shape';
import { getPathWithBorderRadiusByPolyline } from './polyline-util';
import { RouterCfg, pathFinder } from './router';
import { INode } from '../../interface/item';
// 折线
Shape.registerEdge(
'polyline',
{
options: {
color: Global.defaultEdge.color,
size: Global.defaultEdge.size,
style: {
radius: 0,
offset: 15,
x: 0,
y: 0,
stroke: Global.defaultEdge.style.stroke,
lineAppendWidth: Global.defaultEdge.style.lineAppendWidth
},
// 文本样式配置
labelCfg: {
style: {
fill: Global.edgeLabel.style.fill,
fontSize: Global.edgeLabel.style.fontSize,
},
},
routeCfg: {
obstacles: [], // 希望边绕过的障碍节点
maxAllowedDirectionChange: 90, // 允许的最大转角
maximumLoops: 1000,
gridSize: 10, // 指定精度
},
stateStyles: {
...Global.edgeStateStyles
}
},
shapeType: 'polyline',
// 文本位置
labelPosition: 'center',
drawShape(cfg: EdgeConfig, group: Group) {
const shapeStyle = (this as any).getShapeStyle(cfg);
if (shapeStyle.radius === 0) delete shapeStyle.radius;
const keyShape = group.addShape('path', {
className: 'edge-shape',
name: 'edge-shape',
attrs: shapeStyle,
});
return keyShape;
},
getShapeStyle(cfg: EdgeConfig): ShapeStyle {
const { style: defaultStyle } = this.options;
const strokeStyle: ShapeStyle = {
stroke: cfg.color,
};
const style: ShapeStyle = mix({}, defaultStyle, strokeStyle, cfg.style);
cfg = (this as any).getPathPoints(cfg);
this.radius = style.radius;
this.offset = style.offset;
const { startPoint, endPoint } = cfg;
const controlPoints = (this as any).getControlPoints(cfg);
let points = [startPoint]; // 添加起始点
// 添加控制点
if (controlPoints) {
points = points.concat(controlPoints);
}
// 添加结束点
points.push(endPoint);
const source = cfg.sourceNode;
const target = cfg.targetNode;
const radius = style.radius;
const { routeCfg } = this.options;
routeCfg.offset = style.offset;
let path = (this as any).getPath(points, source, target, radius, routeCfg);
if ((isArray(path) && path.length <= 1) || (isString(path) && path.indexOf('L') === -1)) {
path = 'M0 0, L0 0';
}
if (isNaN(startPoint.x) || isNaN(startPoint.y) || isNaN(endPoint.x) || isNaN(endPoint.y)) {
path = 'M0 0, L0 0';
}
const attrs: ShapeStyle = mix({}, Global.defaultEdge.style as ShapeStyle, style, {
lineWidth: cfg.size,
path,
} as ShapeStyle);
return attrs;
},
updateShapeStyle(cfg: EdgeConfig, item: Item) {
const group = item.getContainer();
const strokeStyle: ShapeStyle = {
stroke: cfg.color,
};
const shape =
group.find((element) => element.get('className') === 'edge-shape') || item.getKeyShape();
const { size } = cfg;
cfg = this.getPathPoints!(cfg);
const { startPoint, endPoint } = cfg;
const controlPoints = this.getControlPoints!(cfg); // || cfg.controlPoints;
let points = [startPoint]; // 添加起始点
// 添加控制点
if (controlPoints) {
points = points.concat(controlPoints);
}
// 添加结束点
points.push(endPoint);
const currentAttr = shape.attr();
const previousStyle = mix({}, strokeStyle, currentAttr, cfg.style);
const source = cfg.sourceNode;
const target = cfg.targetNode;
const radius = previousStyle.radius;
const { routeCfg } = this.options;
routeCfg.offset = previousStyle.offset;
let path = (this as any).getPath(points, source, target, radius, routeCfg);
if ((isArray(path) && path.length <= 1) || (isString(path) && path.indexOf('L') === -1)) {
path = 'M0 0, L0 0';
}
if (isNaN(startPoint.x) || isNaN(startPoint.y) || isNaN(endPoint.x) || isNaN(endPoint.y)) {
path = 'M0 0, L0 0';
}
if (currentAttr.endArrow && previousStyle.endArrow === false) {
cfg.style.endArrow = {
path: ''
};
}
if (currentAttr.startArrow && previousStyle.startArrow === false) {
cfg.style.startArrow = {
path: ''
};
}
const style = mix(
strokeStyle,
shape.attr(),
{
lineWidth: size,
path
},
cfg.style,
);
if (shape) {
shape.attr(style);
}
},
getPath(
points: Point[],
source: INode,
target: INode,
radius: number,
routeCfg?: RouterCfg,
): Array<Array<string | number>> | string {
const { offset } = routeCfg;
// 指定了控制点
if (!offset || points.length > 2) {
if (radius) {
return getPathWithBorderRadiusByPolyline(points, radius);
}
const pathArray: Array<Array<string | number>> = [];
each(points, (point, index) => {
if (index === 0) {
pathArray.push(['M', point.x, point.y]);
} else {
pathArray.push(['L', point.x, point.y]);
}
});
return pathArray;
}
// 未指定控制点
let polylinePoints: any;
if (radius) {
polylinePoints = pathFinder(points[0], points[points.length - 1], source, target, routeCfg);
const res = getPathWithBorderRadiusByPolyline(polylinePoints, radius);
return res;
}
polylinePoints = pathFinder(points[0], points[points.length - 1], source, target, routeCfg);
const res = pointsToPolygon(polylinePoints);
return res;
},
},
'single-edge',
);

View File

@ -0,0 +1,429 @@
/**
* costFunc, distFunc, constraints router
* generalRouter: 不限制搜索时的移动方向
* orthogonal: 线必须沿着竖直或水平方向4
* octolinearRouter: 线沿着竖直线8
*/
import { INode } from '../../interface/item';
import { Item } from '../../types';
import { deepMix } from '@antv/util';
import { getLineIntersect } from '../../util/math';
import {
getExpandedBBox,
getExpandedBBoxPoint,
PolyPoint,
getPolylinePoints,
simplifyPolyline,
isSegmentCrossingBBox,
} from './polyline-util';
export interface RouterCfg {
offset?: number; // 连线和点的间距
gridSize?: number;
obstacles?: Item[];
maxAllowedDirectionChange?: number; // 允许的最大转角
directions?: any[]; // 允许的边的方向
startDirections?: string[]; // 边从点出发的方向e.g. 从上拐 / 从下拐)
penalties?: {}; // 附加的分数
distFunc?: (p1: PolyPoint, p2: PolyPoint) => number;
fallbackRoute?: (
p1: PolyPoint,
p2: PolyPoint,
startNode?: INode,
endNode?: INode,
cfg?: RouterCfg,
) => PolyPoint[]; // 若找不到符合要求的路径时的退化路径方案
maximumLoops?: number;
}
const manhattanDist = (p1: PolyPoint, p2: PolyPoint): number =>
Math.abs(p1.x - p2.x) + Math.abs(p1.y - p2.y);
const eucliDist = (p1: PolyPoint, p2: PolyPoint): number =>
Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2);
const straightPath = (start: PolyPoint, end: PolyPoint): PolyPoint[] => {
// console.warn('fallbackRoute: straight path');
return [start, end];
};
const simplePolyline = (
start: PolyPoint,
end: PolyPoint,
startNode: INode,
endNode: INode,
cfg: RouterCfg,
) => {
// console.warn('fallbackRoute: simple polyline path');
return simplifyPolyline(getPolylinePoints(start, end, startNode, endNode, cfg.offset));
};
// getPolylinePoints
const defaultCfg: RouterCfg = {
offset: 20,
maxAllowedDirectionChange: 90,
maximumLoops: 2000,
gridSize: 10,
directions: [
{ stepX: 1, stepY: 0 }, // right
{ stepX: -1, stepY: 0 }, // left
{ stepX: 0, stepY: 1 }, // bottom
{ stepX: 0, stepY: -1 }, // top
],
get penalties() {
return {
0: 0,
45: this.gridSize / 2,
90: this.gridSize / 2,
};
},
distFunc: manhattanDist,
fallbackRoute: simplePolyline,
};
export const octolinearCfg: RouterCfg = {
maxAllowedDirectionChange: 45,
// 8 个方向: 上下左右 + 45度斜线方向
directions: [
{ stepX: 1, stepY: 0 },
{ stepX: 1, stepY: 1 },
{ stepX: 0, stepY: 1 },
{ stepX: -1, stepY: 1 },
{ stepX: -1, stepY: 0 },
{ stepX: -1, stepY: -1 },
{ stepX: 0, stepY: -1 },
{ stepX: 1, stepY: -1 },
],
distFunc: eucliDist,
fallbackRoute: straightPath,
};
const pos2GridIx = (pos: number, gridSize: number) => {
const gridIx = Math.floor(pos / gridSize);
return gridIx < 0 ? 0 : gridIx;
};
const getObstacleMap = (items: Item[], gridSize: number, offset: number) => {
const map = {};
items.forEach((item: Item) => {
// create-edge 时,当边类型为 polyline 时 endNode 为 null
if (item) {
const bbox = getExpandedBBox(item.getBBox(), offset);
for (let x = pos2GridIx(bbox.minX, gridSize); x <= pos2GridIx(bbox.maxX, gridSize); x += 1) {
for (let y = pos2GridIx(bbox.minY, gridSize); y <= pos2GridIx(bbox.maxY, gridSize); y += 1) {
const gridKey = `${x}-${y}`;
map[gridKey] = true;
}
}
}
});
return map;
};
/**
* p1 p2 线线0°线
* @param p1 PolyPoint
* @param p2 PolyPoint
*/
const getDirectionAngle = (p1: PolyPoint, p2: PolyPoint) => {
const deltaX = p2.x - p1.x;
const deltaY = p2.y - p1.y;
if (!deltaX && !deltaY) return 0;
const angle = (360 + (Math.atan2(deltaY, deltaX) * 180) / Math.PI) % 360;
return angle;
};
/**
* 180
* @param angle1
* @param angle2
*/
const getAngleDiff = (angle1: number, angle2: number) => {
const directionChange = Math.abs(angle1 - angle2);
return directionChange > 180 ? 360 - directionChange : directionChange;
};
// Path finder //
const estimateCost = (from: PolyPoint, endPoints: PolyPoint[], distFunc) => {
let min = Infinity;
for (let i = 0, len = endPoints.length; i < len; i++) {
const cost = distFunc(from, endPoints[i]);
if (cost < min) {
min = cost;
}
}
return min;
};
// 计算考虑 offset 后的 BBox 上的连接点
const getBoxPoints = (point: PolyPoint, node: INode, cfg: RouterCfg): PolyPoint[] => {
const points = [];
// create-edge 生成边的过程中endNode 为 null
if (!node) {
return points
}
const { directions, offset } = cfg;
const bbox = node.getBBox();
const expandBBox = getExpandedBBox(node.getBBox(), offset);
for (const i in expandBBox) {
expandBBox[i] = pos2GridIx(expandBBox[i], cfg.gridSize);
}
const isInside =
point.x > pos2GridIx(bbox.minX, cfg.gridSize) &&
point.x < pos2GridIx(bbox.maxX, cfg.gridSize) &&
point.y > pos2GridIx(bbox.minY, cfg.gridSize) &&
point.y < pos2GridIx(bbox.maxY, cfg.gridSize);
if (isInside) {
// 如果 anchorPoint 在节点内部,允许第一段线穿过节点
for (const dir of directions) {
const bounds = [
[
{
x: expandBBox.minX,
y: expandBBox.minY,
},
{
x: expandBBox.maxX,
y: expandBBox.minY,
},
],
[
{
x: expandBBox.minX,
y: expandBBox.minY,
},
{
x: expandBBox.minX,
y: expandBBox.maxY,
},
],
[
{
x: expandBBox.maxX,
y: expandBBox.minY,
},
{
x: expandBBox.maxX,
y: expandBBox.maxY,
},
],
[
{
x: expandBBox.minX,
y: expandBBox.maxY,
},
{
x: expandBBox.maxX,
y: expandBBox.maxY,
},
],
];
for (let i = 0; i < 4; i++) {
const boundLine = bounds[i];
const insterctP = getLineIntersect(
point,
{ x: point.x + dir.stepX * expandBBox.width, y: point.y + dir.stepY * expandBBox.height },
boundLine[0],
boundLine[1],
) as PolyPoint;
if (insterctP && !isSegmentCrossingBBox(point, insterctP, node.getBBox())) {
insterctP.id = `${insterctP.x}-${insterctP.y}`;
points.push(insterctP);
}
}
}
} else {
// 如果 anchorPoint 在节点上,只有一个可选方向
const insterctP = getExpandedBBoxPoint(expandBBox, point);
insterctP.id = `${insterctP.x}-${insterctP.y}`;
points.push(insterctP);
}
return points;
};
export const pathFinder = (
startPoint: PolyPoint,
endPoint: PolyPoint,
startNode: INode,
endNode: INode,
routerCfg?: RouterCfg,
): PolyPoint[] => {
const cfg: RouterCfg = deepMix(defaultCfg, routerCfg);
cfg.obstacles = cfg.obstacles || [];
const map = getObstacleMap(cfg.obstacles.concat([startNode, endNode]), cfg.gridSize, cfg.offset);
const scaleStartPoint = {
x: pos2GridIx(startPoint.x, cfg.gridSize),
y: pos2GridIx(startPoint.y, cfg.gridSize),
};
const scaleEndPoint = {
x: pos2GridIx(endPoint.x, cfg.gridSize),
y: pos2GridIx(endPoint.y, cfg.gridSize),
};
startPoint.id = `${scaleStartPoint.x}-${scaleStartPoint.y}`;
endPoint.id = `${scaleEndPoint.x}-${scaleEndPoint.y}`;
const startPoints = getBoxPoints(scaleStartPoint, startNode, cfg);
const endPoints = getBoxPoints(scaleEndPoint, endNode, cfg);
startPoints.forEach((point) => {
delete map[point.id];
});
endPoints.forEach((point) => {
delete map[point.id];
});
const openSet = {};
const closedSet = {};
const cameFrom: {
[key: string]: string;
} = {};
// 从起点到当前点已产生的 cost, default: Infinity
const gScore: {
[key: string]: number;
} = {};
// 起点经过当前点到达终点预估的 cost, default: Infinity
const fScore: {
[key: string]: number;
} = {};
// initialize
for (let i = 0; i < startPoints.length; i++) {
const firstStep = startPoints[i];
openSet[firstStep.id] = firstStep;
// cameFrom[firstStep.id] = startPoint.id;
gScore[firstStep.id] = 0;
fScore[firstStep.id] = estimateCost(firstStep, endPoints, cfg.distFunc);
}
const getDirectionChange = (current: PolyPoint, neighbor: PolyPoint): number => {
const directionAngle = getDirectionAngle(current, neighbor);
let directionChange: number;
if (!cameFrom[current.id]) {
const startAngle = getDirectionAngle(scaleStartPoint, current);
directionChange = getAngleDiff(startAngle, directionAngle);
} else {
const prevDirectionAngle = getDirectionAngle(
{
x: parseFloat(cameFrom[current.id].split('-')[0]),
y: parseFloat(cameFrom[current.id].split('-')[1]),
},
current,
);
directionChange = getAngleDiff(prevDirectionAngle, directionAngle);
}
return directionChange;
};
const getControlPoints = (currentId: string) => {
const controlPoints = [endPoint];
const lastPoint = {
x: parseFloat(currentId.split('-')[0]),
y: parseFloat(currentId.split('-')[1]),
id: currentId,
};
if (getDirectionChange(lastPoint, scaleEndPoint)) {
controlPoints.unshift({
x: lastPoint.x === scaleEndPoint.x ? endPoint.x : lastPoint.x * cfg.gridSize,
y: lastPoint.y === scaleEndPoint.y ? endPoint.y : lastPoint.y * cfg.gridSize,
});
}
while (cameFrom[currentId] && cameFrom[currentId] !== currentId) {
const point = {
x: parseFloat(currentId.split('-')[0]),
y: parseFloat(currentId.split('-')[1]),
id: currentId,
};
const preId = cameFrom[currentId];
const prePoint = {
x: parseFloat(preId.split('-')[0]),
y: parseFloat(preId.split('-')[1]),
id: preId,
};
const directionChange = getDirectionChange(prePoint, point);
if (directionChange) {
controlPoints.unshift({
x: prePoint.x === point.x ? controlPoints[0].x : prePoint.x * cfg.gridSize,
y: prePoint.y === point.y ? controlPoints[0].y : prePoint.y * cfg.gridSize,
});
}
currentId = preId;
}
// 和startNode对齐
const firstPoint = {
x: parseFloat(currentId.split('-')[0]),
y: parseFloat(currentId.split('-')[1]),
id: currentId,
};
controlPoints[0].x = firstPoint.x === scaleStartPoint.x ? startPoint.x : controlPoints[0].x;
controlPoints[0].y = firstPoint.y === scaleStartPoint.y ? startPoint.y : controlPoints[0].y;
controlPoints.unshift(startPoint);
return controlPoints;
};
let remainLoops = cfg.maximumLoops;
while (Object.keys(openSet).length > 0 && remainLoops > 0) {
let current;
let curCost = Infinity;
// 找到 openSet 中 fScore 最小的点
for (const id in openSet) {
if (fScore[id] <= curCost) {
curCost = fScore[id];
current = openSet[id];
}
}
if (!current) break;
// 如果 fScore 最小的点就是终点
if (endPoints.findIndex((point) => point.x === current.x && point.y === current.y) > -1) {
const controlPoints = getControlPoints(current.id);
return controlPoints;
}
delete openSet[current.id];
closedSet[current.id] = true;
// 获取符合条件的下一步的候选连接点
// 沿候选方向走一步
for (let i = 0; i < cfg.directions.length; i++) {
const direction = cfg.directions[i];
const neighbor = {
x: current.x + direction.stepX,
y: current.y + direction.stepY,
id: `${current.x + direction.stepX}-${current.y + direction.stepY}`,
};
if (closedSet[neighbor.id]) continue;
const directionChange = getDirectionChange(current, neighbor);
if (directionChange > cfg.maxAllowedDirectionChange) continue;
if (map[neighbor.id]) continue; // 如果交叉则跳过
// 将候选点加入 openSet, 并计算每个候选点的 cost
if (!openSet[neighbor.id]) {
openSet[neighbor.id] = neighbor;
}
const neighborCost =
cfg.distFunc(current, neighbor) +
(isNaN(cfg.penalties[directionChange]) ? cfg.gridSize : cfg.penalties[directionChange]);
const costFromStart = gScore[current.id] + neighborCost;
if (gScore[neighbor.id] && costFromStart >= gScore[neighbor.id]) {
continue;
}
cameFrom[neighbor.id] = current.id;
gScore[neighbor.id] = costFromStart;
fScore[neighbor.id] = costFromStart + estimateCost(neighbor, endPoints, cfg.distFunc);
}
remainLoops -= 1;
}
return cfg.fallbackRoute(startPoint, endPoint, startNode, endNode, cfg);
};

View File

@ -0,0 +1,653 @@
import { IPoint, IBBox, Item, BubblesetCfg } from '../../types';
import {
squareDist,
pointLineSquareDist,
itemIntersectByLine,
getPointsCenter,
fractionToLine,
isPointsOverlap,
pointRectSquareDist,
Line,
isPointInPolygon,
} from '../../util/math';
const defaultOps = {
maxRoutingIterations: 100, // number of times to run the algorithm to refine the path finding in difficult areas
maxMarchingIterations: 100, // number of times to refine the boundary
pixelGroupSize: 2, // the resolution of the algorithm in square pixels
edgeR0: 10, // the distance from edges at which energy is 1 (full influence)
edgeR1: 10, // the distance from edges at which energy is 0 (no influence)
nodeR0: 5, // the distance from nodes which energy is 1 (full influence)
nodeR1: 10, // the distance from nodes at which energy is 0 (no influence)
morphBuffer: 5, // DEFAULT_NODE_R0; the amount of space to move the virtual edge when wrapping around obstacles
threshold: 0.001,
skip: 16,
nodeInfluenceFactor: 1,
edgeInfluenceFactor: 1,
negativeNodeInfluenceFactor: -0.5,
};
/**
* Marching square algorithm for traching the contour of a pixel group
* https://www.emanueleferonato.com/2013/03/01/using-marching-squares-algorithm-to-trace-the-contour-of-an-image/
* @param potentialArea
* @param threshold
*/
function MarchingSquares(contour, potentialArea, threshold) {
let marched = false;
const getVal = (x: number, y: number) => {
return potentialArea.cells[x + y * potentialArea.width];
};
const getState = (x: number, y: number) => {
let squareVal = 0;
if (getVal(x - 1, y - 1) >= threshold) {
squareVal += 1;
}
if (getVal(x, y - 1) > threshold) {
squareVal += 2;
}
if (getVal(x - 1, y) > threshold) {
squareVal += 4;
}
if (getVal(x, y) > threshold) {
squareVal += 8;
}
return squareVal;
};
const doMarch = (xPos: number, yPos: number) => {
let x = xPos;
let y = yPos;
let prevX;
let prevY;
for (let i = 0; i < potentialArea.width * potentialArea.height; i++) {
prevX = x;
prevY = y;
if (contour.findIndex((item) => item.x === x && item.y === y) > -1) {
if (contour[0].x !== x || contour[0].y !== y) {
// encountered a loop but haven't returned to start: change direction using conditionals and continue back to start
} else {
return true;
}
} else {
contour.push({ x, y });
}
const state = getState(x, y);
// assign the move direction according to state of the square
switch (state) {
case -1:
console.warn('Marched out of bounds');
return true;
case 0:
case 3:
case 2:
case 7:
x++; // go right
break;
case 12:
case 14:
case 4:
x--; // go left
break;
case 6: // go left if come from up else go right
if (prevX === 0) {
if (prevY === -1) {
x -= 1;
} else {
x += 1;
}
}
break;
case 1:
case 13:
case 5:
y--; // go up
break;
case 9: // go up if come from right else go down
if (prevX === 1) {
if (prevY === 0) {
y -= 1;
} else {
y += 1;
}
}
break;
case 10:
case 8:
case 11:
y++; // go down
break;
default:
console.warn(`Marching squares invalid state: ${state}`);
return true;
}
}
};
this.march = () => {
for (let x = 0; x < potentialArea.width && !marched; x += 1) {
for (let y = 0; y < potentialArea.height && !marched; y += 1) {
if (getVal(x, y) > threshold && getState(x, y) !== 15) {
marched = doMarch(x, y);
}
}
}
return marched;
};
}
/**
* Space partition & assign value to each cell
* @param points
*/
const initGridCells = (width: number, height: number, pixelGroupSize: number) => {
const scaleWidth = Math.ceil(width / pixelGroupSize);
const scaleHeight = Math.ceil(height / pixelGroupSize);
const gridCells = new Float32Array(Math.max(0, scaleWidth * scaleHeight)).fill(0);
return {
cells: gridCells,
width: scaleWidth,
height: scaleHeight,
};
};
/**
* Find the optimal already visited member to item;
Optimal: minimize cost(j) = distance(i,j) countObstacles(i,j)
* @param item
* @param visited
*/
const pickBestNeighbor = (item: Item, visited: Item[], nonMembers: Item[]): Item | null => {
let closestNeighbour = null;
let minCost = Number.POSITIVE_INFINITY;
visited.forEach((neighbourItem) => {
const itemP = { x: item.getModel().x, y: item.getModel().y };
const neighbourItemP = { x: neighbourItem.getModel().x, y: neighbourItem.getModel().y };
const dist = squareDist(itemP, neighbourItemP);
const directLine = new Line(itemP.x, itemP.y, neighbourItemP.x, neighbourItemP.y);
const numberObstacles = nonMembers.reduce((count, _item) => {
if (fractionToLine(_item, directLine) > 0) {
return count + 1;
}
return count;
}, 0);
if (dist * (numberObstacles + 1) ** 2 < minCost) {
closestNeighbour = neighbourItem;
minCost = dist * (numberObstacles + 1) ** 2;
}
});
return closestNeighbour;
};
/**
* 线item中item
* @param items
* @param line
*/
const getIntersectItem = (items: Item[], line: Line): Item | null => {
let minDistance = Number.POSITIVE_INFINITY;
let closestItem = null;
items.forEach((item) => {
const distance = fractionToLine(item, line);
// find closest intersection
if (distance >= 0 && distance < minDistance) {
closestItem = item;
minDistance = distance;
}
});
return closestItem;
};
/**
* Modify the directLine and Route virtual edges around obstacles
*/
const computeRoute = (
directLine: Line,
nonMembers: Item[],
maxRoutingIterations: number,
morphBuffer: number,
): Line[] => {
const checkedLines: Line[] = [];
const linesToCheck: Line[] = [];
linesToCheck.push(directLine);
let hasIntersection = true;
let iterations = 0;
const pointExists = (point: IPoint, lines: Line[]) => {
let flag = false;
lines.forEach((line) => {
if (flag) return;
if (
isPointsOverlap(point, { x: line.x1, y: line.y1 }) ||
isPointsOverlap(point, { x: line.x2, y: line.y2 })
) {
flag = true;
}
});
return flag;
};
const isPointInNonMembers = (point: IPoint, _nonMembers: Item[]) => {
for (const item of _nonMembers) {
const bbox = item.getBBox();
const itemContour = [
[bbox.x, bbox.y],
[bbox.x + bbox.width, bbox.y],
[bbox.x, bbox.y + bbox.height],
[bbox.x + bbox.width, bbox.y + bbox.height],
];
if (isPointInPolygon(itemContour, point.x, point.y)) {
return true;
}
}
return false;
};
// outer loop end when no more intersections or out of iterations
while (hasIntersection && iterations < maxRoutingIterations) {
hasIntersection = false;
// inner loop end when out of lines or found an intersection
while (!hasIntersection && linesToCheck.length) {
const line = linesToCheck.pop();
const closestItem = getIntersectItem(nonMembers, line);
if (closestItem) {
const [intersections, countIntersections] = itemIntersectByLine(closestItem, line);
// if line passes through item
if (countIntersections === 2) {
const testReroute = (isFirst: boolean) => {
let tempMorphBuffer = morphBuffer;
let virtualNode = rerouteLine(closestItem, tempMorphBuffer, intersections, isFirst);
// test the virtualNode already exists
let exist =
pointExists(virtualNode, linesToCheck) || pointExists(virtualNode, checkedLines);
let pointInside = isPointInNonMembers(virtualNode, nonMembers);
while (!exist && pointInside && tempMorphBuffer >= 1) {
// try a smaller buffer
tempMorphBuffer /= 1.5;
virtualNode = rerouteLine(closestItem, tempMorphBuffer, intersections, isFirst);
exist =
pointExists(virtualNode, linesToCheck) || pointExists(virtualNode, checkedLines);
pointInside = isPointInNonMembers(virtualNode, nonMembers);
}
// 第二次route时不要求pointInside
if (virtualNode && !exist && (!isFirst || !pointInside)) {
// add 2 rerouted lines to check
linesToCheck.push(new Line(line.x1, line.y1, virtualNode.x, virtualNode.y));
linesToCheck.push(new Line(virtualNode.x, virtualNode.y, line.x2, line.y2));
hasIntersection = true;
}
};
testReroute(true);
if (!hasIntersection) {
// if we didn't find a valid point around the first corner, try the second
testReroute(false);
}
}
}
// no intersection found, mark this line as completed
if (!hasIntersection) {
checkedLines.push(line);
}
iterations += 1;
}
}
// 加入剩余的线
while (linesToCheck.length) {
checkedLines.push(linesToCheck.pop());
}
return checkedLines;
};
/**
* Connect item with visited members using direct line or virtual edges
*/
function getRoute(
item: Item,
nonMembers: Item[],
visited: Item[],
maxRoutingIterations: number,
morphBuffer: number,
) {
const optimalNeighbor = pickBestNeighbor(item, visited, nonMembers);
if (optimalNeighbor === null) {
return [];
}
// merge the consecutive lines
const mergeLines = (checkedLines: Line[]): Line[] => {
const finalRoute: Line[] = [];
while (checkedLines.length > 0) {
const line1 = checkedLines.pop()!;
if (checkedLines.length === 0) {
finalRoute.push(line1);
break;
}
const line2 = checkedLines.pop()!;
const mergeLine = new Line(line1.x1, line1.y1, line2.x2, line2.y2);
const closestItem = getIntersectItem(nonMembers, mergeLine);
// merge most recent line and previous line
if (!closestItem) {
checkedLines.push(mergeLine);
} else {
finalRoute.push(line1);
checkedLines.push(line2);
}
}
return finalRoute;
};
const directLine = new Line(
item.getModel().x,
item.getModel().y,
optimalNeighbor.getModel().x,
optimalNeighbor.getModel().y,
);
const checkedLines = computeRoute(directLine, nonMembers, maxRoutingIterations, morphBuffer);
const finalRoute = mergeLines(checkedLines);
return finalRoute;
}
/**
* Calculate the countor that includes the selected items and exclues the non-selected items
* @param graph
* @param members
* @param nonMembers
* @param options
*/
export const genBubbleSet = (members: Item[], nonMembers: Item[], ops?: BubblesetCfg) => {
// eslint-disable-next-line no-redeclare
const options = Object.assign(defaultOps, ops);
const centroid = getPointsCenter(
members.map((item) => ({ x: item.getModel().x, y: item.getModel().y })),
);
// 按照到中心距离远近排序
members = members.sort(
(a, b) =>
squareDist({ x: a.getModel().x, y: a.getModel().y }, centroid) -
squareDist({ x: b.getModel().x, y: b.getModel().y }, centroid),
);
const visited: Item[] = [];
const virtualEdges: Line[] = [];
members.forEach((item) => {
const lines = getRoute(
item,
nonMembers,
visited,
options.maxRoutingIterations,
options.morphBuffer,
);
lines.forEach((l) => {
virtualEdges.push(l);
});
visited.push(item);
});
// 由于edge也可以作为member和nonMember传入暂时不考虑把edges作为参数传入genBubbleSet
// edges && edges.forEach(e => {
// virtualEdges.push(new Line(e.getSource().getModel().x, e.getSource().getModel().y, e.getTarget().getModel().x, e.getTarget().getModel().y));
// });
const activeRegion = getActiveRregion(members, virtualEdges, options.nodeR0);
const potentialArea = initGridCells(
activeRegion.width,
activeRegion.height,
options.pixelGroupSize,
);
// Use march squares to generate contour
let contour = [];
let hull = [];
for (let iterations = 0; iterations < options.maxMarchingIterations; iterations++) {
fillPotentialArea(members, nonMembers, virtualEdges, activeRegion, potentialArea, options);
contour = [];
hull = [];
if (!new MarchingSquares(contour, potentialArea, options.threshold).march()) continue;
const marchedPath = contour.map((point) => ({
x: Math.round(point.x * options.pixelGroupSize + activeRegion.minX),
y: Math.round(point.y * options.pixelGroupSize + activeRegion.minY),
}));
// const marchedPath = marchingSquares(potentialArea, options.threshold).map(point => ({ x: Math.round(point.x * options.pixelGroupSize + activeRegion.minX), y: Math.round(point.y * options.pixelGroupSize + activeRegion.minY) }))
if (marchedPath) {
let size = marchedPath.length;
if (options.skip > 1) {
size = Math.floor(marchedPath.length / options.skip);
// if we reduced too much (fewer than three points in reduced surface) reduce skip and try again
while (size < 3 && options.skip > 1) {
options.skip -= 1;
size = Math.floor(marchedPath.length / options.skip);
}
}
// copy hull values
for (let i = 0, j = 0; j < size; j += 1, i += options.skip) {
hull.push({ x: marchedPath[i].x, y: marchedPath[i].y });
}
}
const isContourValid = () => {
for (const item of members) {
const hullPoints = hull.map((point) => [point.x, point.y]);
if (!isPointInPolygon(hullPoints, item.getBBox().centerX, item.getBBox().centerY))
return false;
}
// 不强制要求所有nonMembers都没有包含在内
// for (const item of nonMembers) {
// if (isPointInPolygon({ x: item.getBBox().centerX, y: item.getBBox().centerY }, contour)) return false
// }
return true;
};
if (hull && isContourValid()) {
return hull;
}
// update parameters for next iteraction
options.threshold *= 0.9;
if (iterations <= options.maxMarchingIterations * 0.5) {
options.memberInfluenceFactor *= 1.2;
options.edgeInfluenceFactor *= 1.2;
} else if (options.nonMemberInfluenceFactor !== 0 && nonMembers.length > 0) {
// after half the iterations, start increasing positive energy and lowering the threshold
options.nonMemberInfluenceFactor *= 0.8;
} else {
break;
}
}
return hull;
};
/**
* unionboundingbox
* @param members
* @param edges
*/
function getActiveRregion(members: Item[], edges: Line[], offset: number): IBBox {
const activeRegion = {
minX: Number.POSITIVE_INFINITY,
minY: Number.POSITIVE_INFINITY,
maxX: Number.NEGATIVE_INFINITY,
maxY: Number.NEGATIVE_INFINITY,
width: 0,
height: 0,
x: 0,
y: 0,
};
const bboxes = [];
members.forEach((item) => {
bboxes.push(item.getBBox());
});
edges.forEach((l) => {
bboxes.push(l.getBBox());
});
for (const bbox of bboxes) {
activeRegion.minX = (bbox.minX < activeRegion.minX ? bbox.minX : activeRegion.minX) - offset;
activeRegion.minY = (bbox.minY < activeRegion.minY ? bbox.minY : activeRegion.minY) - offset;
activeRegion.maxX = (bbox.maxX > activeRegion.maxX ? bbox.maxX : activeRegion.maxX) + offset;
activeRegion.maxY = (bbox.maxY > activeRegion.maxY ? bbox.maxY : activeRegion.maxY) + offset;
}
activeRegion.width = activeRegion.maxX - activeRegion.minX;
activeRegion.height = activeRegion.maxY - activeRegion.minY;
activeRegion.x = activeRegion.minX;
activeRegion.y = activeRegion.minY;
return activeRegion;
}
function fillPotentialArea(
members: Item[],
nonMembers: Item[],
edges: Line[],
activeRegion: IBBox,
potentialArea,
options: BubblesetCfg,
) {
function pos2GridIx(x, offset) {
const gridIx = Math.floor((x - offset) / options.pixelGroupSize);
return gridIx < 0 ? 0 : gridIx;
}
function gridIx2Pos(x, offset) {
return x * options.pixelGroupSize + offset;
}
// using inverse a for numerical stability
const nodeInfA = (options.nodeR0 - options.nodeR1) * (options.nodeR0 - options.nodeR1);
const edgeInfA = (options.edgeR0 - options.edgeR1) * (options.edgeR0 - options.edgeR1);
const getAffectedRegion = (bbox, thresholdR) => {
const startX = Math.min(
pos2GridIx(bbox.minX, thresholdR + activeRegion.minX),
potentialArea.width,
);
const startY = Math.min(
pos2GridIx(bbox.minY, thresholdR + activeRegion.minY),
potentialArea.height,
);
const endX = Math.min(
pos2GridIx(bbox.maxX, -thresholdR + activeRegion.minX),
potentialArea.width,
);
const endY = Math.min(
pos2GridIx(bbox.maxY, -thresholdR + activeRegion.minY),
potentialArea.height,
);
return [startX, startY, endX, endY];
};
const addItemInfluence = (item: Item, influenceFactor: number) => {
const bbox = item.getBBox();
const [startX, startY, endX, endY] = getAffectedRegion(bbox, options.nodeR1);
// calculate item influence for each cell
for (let y = startY; y < endY; y += 1) {
for (let x = startX; x < endX; x += 1) {
if (influenceFactor < 0 && potentialArea[x + y * potentialArea.width] <= 0) {
continue;
}
const tempX = gridIx2Pos(x, activeRegion.minX);
const tempY = gridIx2Pos(y, activeRegion.minY);
const distanceSq = pointRectSquareDist(
{ x: tempX, y: tempY },
{ x: bbox.minX, y: bbox.minY, width: bbox.width, height: bbox.height }
);
if (distanceSq < options.nodeR1 ** 2) {
const dr = Math.sqrt(distanceSq) - options.nodeR1;
potentialArea.cells[x + y * potentialArea.width] += influenceFactor * dr * dr;
}
}
}
};
const addEdgeInfluence = (line: Line, influenceFactor: number) => {
const bbox = line.getBBox();
const [startX, startY, endX, endY] = getAffectedRegion(bbox, options.edgeR1);
// for every point in active part of potentialArea, calculate distance to nearest point on line and add influence
for (let y = startY; y < endY; y += 1) {
for (let x = startX; x < endX; x += 1) {
if (influenceFactor < 0 && potentialArea.cells[x + y * potentialArea.width] <= 0) {
continue;
}
const tempX = gridIx2Pos(x, activeRegion.minX);
const tempY = gridIx2Pos(y, activeRegion.minY);
const minDistanceSq = pointLineSquareDist({ x: tempX, y: tempY }, line);
// only influence if less than r1
if (minDistanceSq < options.edgeR1 ** 2) {
const mdr = Math.sqrt(minDistanceSq) - options.edgeR1;
potentialArea.cells[x + y * potentialArea.width] += influenceFactor * mdr * mdr;
}
}
}
};
if (options.nodeInfluenceFactor) {
members.forEach((item) => {
addItemInfluence(item, options.nodeInfluenceFactor / nodeInfA);
});
}
if (options.edgeInfluenceFactor) {
edges.forEach((edge) => {
addEdgeInfluence(edge, options.edgeInfluenceFactor / edgeInfA);
});
}
if (options.negativeNodeInfluenceFactor) {
nonMembers.forEach((item) => {
addItemInfluence(item, options.negativeNodeInfluenceFactor / nodeInfA);
});
}
}
function rerouteLine(item, buffer: number, intersections: IPoint[], wrapNormal: boolean): IPoint {
const bbox = item.getBBox();
const [topIntersect, leftIntersect, bottomIntersect, rightIntersect] = intersections;
const cornerPos = {
topLeft: { x: bbox.minX - buffer, y: bbox.minY - buffer },
topRight: { x: bbox.maxX + buffer, y: bbox.minY - buffer },
bottomLeft: { x: bbox.minX - buffer, y: bbox.maxY + buffer },
bottomRight: { x: bbox.maxX + buffer, y: bbox.maxY + buffer },
};
const totalArea = bbox.height * bbox.width;
function calcHalfArea(intersect1, intersect2) {
return bbox.width * ((intersect1.y - bbox.minY + (intersect2.y - bbox.minY)) * 0.5);
}
// 根据线和boundingbox相交的情况确定control point的位置
if (leftIntersect) {
// 相交区域有三角形
if (topIntersect) return wrapNormal ? cornerPos.topLeft : cornerPos.bottomRight;
if (bottomIntersect) return wrapNormal ? cornerPos.bottomLeft : cornerPos.topRight;
// 相交区域分成上下两个梯形,比较面积
const topArea = calcHalfArea(leftIntersect, rightIntersect);
if (topArea < totalArea * 0.5) {
if (leftIntersect.y > rightIntersect.y)
return wrapNormal ? cornerPos.topLeft : cornerPos.bottomRight;
return wrapNormal ? cornerPos.topRight : cornerPos.bottomLeft;
}
if (leftIntersect.y < rightIntersect.y)
return wrapNormal ? cornerPos.bottomLeft : cornerPos.topRight;
return wrapNormal ? cornerPos.bottomRight : cornerPos.topLeft;
}
if (rightIntersect) {
if (topIntersect) return wrapNormal ? cornerPos.topRight : cornerPos.bottomLeft;
if (bottomIntersect) return wrapNormal ? cornerPos.bottomRight : cornerPos.topLeft;
}
// 相交区域分成左右两个梯形
const leftArea = calcHalfArea(topIntersect, bottomIntersect);
if (leftArea < totalArea * 0.5) {
if (topIntersect.x > bottomIntersect.x)
return wrapNormal ? cornerPos.topLeft : cornerPos.bottomRight;
return wrapNormal ? cornerPos.bottomLeft : cornerPos.topRight;
}
if (topIntersect.x < bottomIntersect.x)
return wrapNormal ? cornerPos.topRight : cornerPos.bottomLeft;
return wrapNormal ? cornerPos.bottomRight : cornerPos.topLeft;
}

View File

@ -0,0 +1,50 @@
import { IPoint, Item } from '../../types';
/**
* Use cross product to judge the direction of the turn.
* Returns a positive value, if OAB makes a clockwise turn,
* negative for counter-clockwise turn, and zero if the points are collinear.
*/
export const cross = (a: IPoint, b: IPoint, o: IPoint) => {
return (a.y - o.y) * (b.x - o.x) - (a.x - o.x) * (b.y - o.y);
};
/**
* Generate a convex hull of given points. Andrew's monotone chain algorithm.
* @param points An array of [x, y] representing the coordinates of points.
* @return a list of vertices of the convex hull in counter-clockwise order,
*/
export const genConvexHull = (items: Item[]) => {
const points: IPoint[] = items.map((item) => ({ x: item.getModel().x, y: item.getModel().y }));
points.sort((a, b) => {
return a.x === b.x ? a.y - b.y : a.x - b.x;
});
// build the lower hull
const lower = [];
for (let i = 0; i < points.length; i++) {
while (
lower.length >= 2 &&
cross(lower[lower.length - 2], lower[lower.length - 1], points[i]) <= 0
) {
lower.pop();
}
lower.push(points[i]);
}
// build the upper hull
const upper = [];
for (let i = points.length - 1; i >= 0; i--) {
while (
upper.length >= 2 &&
cross(upper[upper.length - 2], upper[upper.length - 1], points[i]) <= 0
) {
upper.pop();
}
upper.push(points[i]);
}
upper.pop();
lower.pop();
const strictHull = lower.concat(upper);
return strictHull;
};

View File

@ -0,0 +1,13 @@
import Shape from './shape';
import './node';
import './edge';
import './combo';
import './combos';
import './nodes';
import './edges';
export { default as Arrow } from './arrow';
export { default as Marker } from './marker';
export default Shape;

View File

@ -0,0 +1,32 @@
export default {
collapse: (x, y, r) => {
return [
['M', x - r, y],
['a', r, r, 0, 1, 0, r * 2, 0],
['a', r, r, 0, 1, 0, -r * 2, 0],
['M', x - r + 4, y],
['L', x + r - 4, y],
];
},
expand: (x, y, r) => {
return [
['M', x - r, y],
['a', r, r, 0, 1, 0, r * 2, 0],
['a', r, r, 0, 1, 0, -r * 2, 0],
['M', x - r + 4, y],
['L', x - r + 2 * r - 4, y],
['M', x - r + r, y - r + 4],
['L', x, y + r - 4],
];
},
upTriangle: (x, y, r) => {
const l1 = r * Math.cos(Math.PI / 6);
const l2 = r * Math.sin(Math.PI / 6);
return [['M', x - l1, y + l2], ['L', x + l1, y + l2], ['L', x, y - r], ['Z']];
},
downTriangle: (x, y, r) => {
const l1 = r * Math.cos(Math.PI / 6);
const l2 = r * Math.sin(Math.PI / 6);
return [['M', x - l1, y - l2], ['L', x + l1, y - l2], ['L', x, y + r], ['Z']];
},
};

View File

@ -0,0 +1,347 @@
/**
* @fileOverview common node shape
* @author huangtonger@aliyun.com
*/
import { Group as GGroup } from '@antv/g-canvas';
import { IShape, IElement } from '@antv/g-canvas/lib/interfaces';
import { isArray, isNil, mix } from '@antv/util';
import { ILabelConfig, ShapeOptions } from '../interface/shape';
import { Item, LabelStyle, NodeConfig, ModelConfig } from '../types';
import { formatPadding } from '../util/base';
import Global from '../global';
import Shape from './shape';
import { shapeBase } from './shapeBase';
const singleNode: ShapeOptions = {
itemType: 'node',
// 单个图形的类型
shapeType: 'single-node',
/**
*
* top, bottom, left, right, center
* @type {String}
*/
labelPosition: 'center',
/**
* labelPosition center
* @type {Number}
*/
offset: Global.nodeLabel.offset,
/**
*
* @internal [width, height]
* @param {Object} cfg
* @return {Array}
*/
getSize(cfg: ModelConfig): number[] {
let size: number | number[] = cfg.size || this.getOptions({})!.size || Global.defaultNode.size;
// size 是数组但长度为1则补长度为2
if (isArray(size) && size.length === 1) {
size = [size[0], size[0]];
}
// size 为数字,则转换为数组
if (!isArray(size)) {
size = [size, size];
}
return size;
},
// 私有方法,不希望扩展的节点复写这个方法
getLabelStyleByPosition(cfg: NodeConfig, labelCfg: ILabelConfig): LabelStyle {
const labelPosition = labelCfg.position || this.labelPosition;
// 默认的位置(最可能的情形),所以放在最上面
if (labelPosition === 'center') {
return { x: 0, y: 0, text: cfg!.label as string };
}
let { offset } = labelCfg;
if (isNil(offset)) {
// 考虑 offset = 0 的场景,不用用 labelCfg.offset || Global.nodeLabel.offset
offset = this.offset as number; // 不居中时的偏移量
}
const size = this.getSize!(cfg as ModelConfig);
const width = size[0];
const height = size[1];
let style: any;
switch (labelPosition) {
case 'top':
style = {
x: 0,
y: 0 - height / 2 - (offset as number),
textBaseline: 'bottom', // 文本在图形的上面
};
break;
case 'bottom':
style = {
x: 0,
y: height / 2 + (offset as number),
textBaseline: 'top',
};
break;
case 'left':
style = {
x: 0 - width / 2 - (offset as number),
y: 0,
textAlign: 'right',
};
break;
default:
style = {
x: width / 2 + (offset as number),
y: 0,
textAlign: 'left',
};
break;
}
style.text = cfg.label;
return style;
},
getLabelBgStyleByPosition(
label: IElement,
cfg: ModelConfig,
labelCfg?: ILabelConfig,
group?: GGroup,
) {
if (!label) {
return {};
}
const bbox = label.getBBox();
const backgroundStyle = labelCfg.style && labelCfg.style.background;
if (!backgroundStyle) {
return {};
}
const padding = formatPadding(backgroundStyle.padding);
const backgroundWidth = bbox.width + padding[1] + padding[3];
const backgroundHeight = bbox.height + padding[0] + padding[2];
let { offset } = labelCfg;
if (isNil(offset)) {
// 考虑 offset = 0 的场景,不用用 labelCfg.offset || Global.nodeLabel.offset
offset = this.offset as number; // 不居中时的偏移量
}
let style: any;
style = {
x: bbox.minX - padding[3],
y: bbox.minY - padding[0]
}
style = {
...style,
...backgroundStyle,
width: backgroundWidth,
height: backgroundHeight,
};
return style;
},
drawShape(cfg: NodeConfig, group: GGroup): IShape {
const { shapeType } = this; // || this.type都已经加了 shapeType
const style = this.getShapeStyle!(cfg);
const shape = group.addShape(shapeType, {
attrs: style,
draggable: true,
name: 'node-shape',
});
return shape;
},
/**
* linkPoints
* @param {Object} cfg
* @param {Group} group Item所在的group
*/
updateLinkPoints(cfg: NodeConfig, group: GGroup) {
const { linkPoints: defaultLinkPoints } = this.getOptions(cfg) as ModelConfig;
const markLeft = group.find((element) => element.get('className') === 'link-point-left');
const markRight = group.find((element) => element.get('className') === 'link-point-right');
const markTop = group.find((element) => element.get('className') === 'link-point-top');
const markBottom = group.find((element) => element.get('className') === 'link-point-bottom');
let currentLinkPoints;
if (markLeft) {
currentLinkPoints = markLeft.attr();
}
if (markRight && !currentLinkPoints) {
currentLinkPoints = markRight.attr();
}
if (markTop && !currentLinkPoints) {
currentLinkPoints = markTop.attr();
}
if (markBottom && !currentLinkPoints) {
currentLinkPoints = markBottom.attr();
}
if (!currentLinkPoints) currentLinkPoints = defaultLinkPoints;
const linkPoints = mix({}, currentLinkPoints, cfg.linkPoints);
const { fill: markFill, stroke: markStroke, lineWidth: borderWidth } = linkPoints;
let markSize = linkPoints.size / 2;
if (!markSize) markSize = linkPoints.r;
const { left, right, top, bottom } = cfg.linkPoints
? cfg.linkPoints
: { left: undefined, right: undefined, top: undefined, bottom: undefined };
const size = this.getSize!(cfg);
const width = size[0];
const height = size[1];
const styles = {
r: markSize,
fill: markFill,
stroke: markStroke,
lineWidth: borderWidth,
};
if (markLeft) {
if (!left && left !== undefined) {
markLeft.remove();
} else {
markLeft.attr({
...styles,
x: -width / 2,
y: 0,
});
}
} else if (left) {
group.addShape('circle', {
attrs: {
...styles,
x: -width / 2,
y: 0,
},
className: 'link-point-left',
name: 'link-point-left',
isAnchorPoint: true,
});
}
if (markRight) {
if (!right && right !== undefined) {
markRight.remove();
}
markRight.attr({
...styles,
x: width / 2,
y: 0,
});
} else if (right) {
group.addShape('circle', {
attrs: {
...styles,
x: width / 2,
y: 0,
},
className: 'link-point-right',
name: 'link-point-right',
isAnchorPoint: true,
});
}
if (markTop) {
if (!top && top !== undefined) {
markTop.remove();
}
markTop.attr({
...styles,
x: 0,
y: -height / 2,
});
} else if (top) {
group.addShape('circle', {
attrs: {
...styles,
x: 0,
y: -height / 2,
},
className: 'link-point-top',
name: 'link-point-top',
isAnchorPoint: true,
});
}
if (markBottom) {
if (!bottom && bottom !== undefined) {
markBottom.remove();
} else {
markBottom.attr({
...styles,
x: 0,
y: height / 2,
});
}
} else if (bottom) {
group.addShape('circle', {
attrs: {
...styles,
x: 0,
y: height / 2,
},
className: 'link-point-bottom',
name: 'link-point-bottom',
isAnchorPoint: true,
});
}
},
updateShape(cfg: NodeConfig, item: Item, keyShapeStyle: object, hasIcon: boolean) {
const keyShape = item.get('keyShape');
keyShape.attr({
...keyShapeStyle,
});
(this as any).updateLabel(cfg, item);
// special for some types of nodes
if (hasIcon) {
(this as any).updateIcon(cfg, item);
}
},
updateIcon(cfg: NodeConfig, item: Item) {
const group = item.getContainer();
const { icon } = this.getOptions(cfg) as NodeConfig;
const { show } = cfg.icon ? cfg.icon : { show: undefined };
const iconShape = group.find((element) => element.get('className') === `${this.type}-icon`);
if (iconShape) {
// 若原先存在 icon
if (show || show === undefined) {
// 若传入 show: true, 或没有设置,则更新原有的 icon 样式
const iconConfig = mix({}, iconShape.attr(), icon);
const { width: w, height: h } = iconConfig;
iconShape.attr({
...iconConfig,
x: -w / 2,
y: -h / 2,
});
} else {
// 若传入了 show: false 则删除原先的 icon
iconShape.remove();
}
} else if (show) {
// 如果原先不存在 icon但传入了 show: true则新增 icon
const { width: w, height: h } = icon;
group.addShape('image', {
attrs: {
...icon,
x: -w! / 2,
y: -h! / 2,
},
className: `${this.type}-icon`,
name: `${this.type}-icon`,
});
// to ensure the label is on the top of all the shapes
const labelShape = group.find((element) => element.get('className') === `node-label`);
if (labelShape) {
labelShape.toFront();
}
}
},
};
const singleNodeDef = { ...shapeBase, ...singleNode };
Shape.registerNode('single-node', singleNodeDef);

View File

@ -0,0 +1,196 @@
import { Group as GGroup } from '@antv/g-canvas';
import { IShape } from '@antv/g-canvas/lib/interfaces';
import { deepMix } from '@antv/util';
import { Item, NodeConfig, ShapeStyle } from '../../types';
import Global from '../../global';
import Shape from '../shape';
import { ShapeOptions } from '../../interface/shape';
// 带有图标的圆,可用于拓扑图中
Shape.registerNode(
'circle',
{
// 自定义节点时的配置
options: {
size: Global.defaultNode.size,
style: {
x: 0,
y: 0,
stroke: Global.defaultNode.style.stroke,
fill: Global.defaultNode.style.fill,
lineWidth: Global.defaultNode.style.lineWidth
},
labelCfg: {
style: {
fill: Global.nodeLabel.style.fill,
fontSize: Global.nodeLabel.style.fontSize,
},
},
// 节点上左右上下四个方向上的链接circle配置
linkPoints: {
top: false,
right: false,
bottom: false,
left: false,
// circle的大小
size: Global.defaultNode.linkPoints.size,
lineWidth: Global.defaultNode.linkPoints.lineWidth,
fill: Global.defaultNode.linkPoints.fill,
stroke: Global.defaultNode.linkPoints.stroke,
},
// 节点中icon配置
icon: {
// 是否显示icon值为 false 则不渲染icon
show: false,
// icon的地址字符串类型
img:
'https://gw.alipayobjects.com/zos/bmw-prod/5d015065-8505-4e7a-baec-976f81e3c41d.svg',
width: 20,
height: 20,
},
stateStyles: {
...Global.nodeStateStyles
}
},
shapeType: 'circle',
// 文本位置
labelPosition: 'center',
drawShape(cfg: NodeConfig, group: GGroup): IShape {
const { icon: defaultIcon = {} } = this.getOptions(cfg) as NodeConfig;
const style = this.getShapeStyle!(cfg);
const icon = deepMix({}, defaultIcon, cfg.icon);
const keyShape: IShape = group.addShape('circle', {
attrs: style,
className: `${this.type}-keyShape`,
draggable: true,
});
const { width, height, show } = icon;
if (show) {
group.addShape('image', {
attrs: {
x: -width / 2,
y: -height / 2,
...icon,
},
className: `${this.type}-icon`,
name: `${this.type}-icon`,
draggable: true,
});
}
(this as any).drawLinkPoints(cfg, group);
return keyShape;
},
/**
* LinkPoints
* @param {Object} cfg data数据配置项
* @param {Group} group Group实例
*/
drawLinkPoints(cfg: NodeConfig, group: GGroup) {
const { linkPoints = {} } = this.getOptions(cfg) as NodeConfig;
const { top, left, right, bottom, size: markSize, r: markR, ...markStyle } = linkPoints;
const size = this.getSize!(cfg);
const r = size[0] / 2;
if (left) {
// left circle
group.addShape('circle', {
attrs: {
...markStyle,
x: -r,
y: 0,
r: markSize / 2 || markR || 5,
},
className: 'link-point-left',
name: 'link-point-left',
isAnchorPoint: true,
});
}
if (right) {
// right circle
group.addShape('circle', {
attrs: {
...markStyle,
x: r,
y: 0,
r: markSize / 2 || markR || 5,
},
className: 'link-point-right',
name: 'link-point-right',
isAnchorPoint: true,
});
}
if (top) {
// top circle
group.addShape('circle', {
attrs: {
...markStyle,
x: 0,
y: -r,
r: markSize / 2 || markR || 5,
},
className: 'link-point-top',
name: 'link-point-top',
isAnchorPoint: true,
});
}
if (bottom) {
// bottom circle
group.addShape('circle', {
attrs: {
...markStyle,
x: 0,
y: r,
r: markSize / 2 || markR || 5,
},
className: 'link-point-bottom',
name: 'link-point-bottom',
isAnchorPoint: true,
});
}
},
/**
* 使
* @param {Object} cfg
* @return {Object}
*/
getShapeStyle(cfg: NodeConfig): ShapeStyle {
const { style: defaultStyle } = this.getOptions(cfg) as NodeConfig;
const strokeStyle = {
stroke: cfg.color,
};
// 如果设置了color则覆盖默认的stroke属性
const style = deepMix({}, defaultStyle, strokeStyle);
const size = (this as ShapeOptions).getSize!(cfg);
const r = size[0] / 2;
const styles = {
x: 0,
y: 0,
r,
...style,
};
return styles;
},
update(cfg: NodeConfig, item: Item) {
const group = item.getContainer();
const size = (this as ShapeOptions).getSize!(cfg);
// 下面这些属性需要覆盖默认样式与目前样式,但若在 cfg 中有指定则应该被 cfg 的相应配置覆盖。
const strokeStyle = {
stroke: cfg.color,
r: size[0] / 2,
};
// 与 getShapeStyle 不同在于update 时需要获取到当前的 style 进行融合。即新传入的配置项中没有涉及的属性,保留当前的配置。
const keyShape = item.get('keyShape');
const style = deepMix({}, keyShape.attr(), strokeStyle, cfg.style);
(this as any).updateShape(cfg, item, style, true);
(this as any).updateLinkPoints(cfg, group);
},
},
'single-node',
);

View File

@ -0,0 +1,206 @@
import { Group as GGroup } from '@antv/g-canvas';
import { IShape } from '@antv/g-canvas/lib/interfaces';
import { mix } from '@antv/util';
import { Item, NodeConfig, ShapeStyle } from '../../types';
import Global from '../../global';
import Shape from '../shape';
// 菱形shape
Shape.registerNode(
'diamond',
{
// 自定义节点时的配置
options: {
size: [80, 80],
style: {
stroke: Global.defaultNode.style.stroke,
fill: Global.defaultNode.style.fill,
lineWidth: Global.defaultNode.style.lineWidth
},
// 文本样式配置
labelCfg: {
style: {
fill: Global.nodeLabel.style.fill,
fontSize: Global.nodeLabel.style.fontSize,
},
},
// 节点上左右上下四个方向上的链接circle配置
linkPoints: {
top: false,
right: false,
bottom: false,
left: false,
// circle的大小
size: Global.defaultNode.linkPoints.size,
lineWidth: Global.defaultNode.linkPoints.lineWidth,
fill: Global.defaultNode.linkPoints.fill,
stroke: Global.defaultNode.linkPoints.stroke,
},
// 节点中icon配置
icon: {
// 是否显示icon值为 false 则不渲染icon
show: false,
// icon的地址字符串类型
img:
'https://gw.alipayobjects.com/zos/bmw-prod/5d015065-8505-4e7a-baec-976f81e3c41d.svg',
width: 20,
height: 20,
},
stateStyles: {
...Global.nodeStateStyles
}
},
shapeType: 'diamond',
// 文本位置
labelPosition: 'center',
drawShape(cfg: NodeConfig, group: GGroup): IShape {
const { icon = {} } = this.getOptions(cfg) as NodeConfig;
const style = this.getShapeStyle!(cfg);
const keyShape = group.addShape('path', {
attrs: style,
className: `${this.type}-keyShape`,
name: `${this.type}-keyShape`,
draggable: true,
});
const { width: w, height: h, show } = icon;
if (show) {
const image = group.addShape('image', {
attrs: {
x: -w! / 2,
y: -h! / 2,
...icon,
},
className: `${this.type}-icon`,
name: `${this.type}-icon`,
draggable: true,
});
}
(this as any).drawLinkPoints(cfg, group);
return keyShape;
},
/**
* LinkPoints
* @param {Object} cfg data数据配置项
* @param {Group} group Group实例
*/
drawLinkPoints(cfg: NodeConfig, group: GGroup) {
const { linkPoints = {} } = this.getOptions(cfg) as NodeConfig;
const { top, left, right, bottom, size: markSize, r: markR, ...markStyle } = linkPoints;
const size = this.getSize!(cfg);
const width = size[0];
const height = size[1];
if (left) {
// left circle
group.addShape('circle', {
attrs: {
...markStyle,
x: -width / 2,
y: 0,
r: markSize / 2 || markR || 5,
},
className: 'link-point-left',
name: 'link-point-left',
isAnchorPoint: true,
});
}
if (right) {
// right circle
group.addShape('circle', {
attrs: {
...markStyle,
x: width / 2,
y: 0,
r: markSize / 2 || markR || 5,
},
className: 'link-point-right',
name: 'link-point-right',
isAnchorPoint: true,
});
}
if (top) {
// top circle
group.addShape('circle', {
attrs: {
...markStyle,
x: 0,
y: -height / 2,
r: markSize / 2 || markR || 5,
},
className: 'link-point-top',
name: 'link-point-top',
isAnchorPoint: true,
});
}
if (bottom) {
// bottom circle
group.addShape('circle', {
attrs: {
...markStyle,
x: 0,
y: height / 2,
r: markSize / 2 || markR || 5,
},
className: 'link-point-bottom',
name: 'link-point-bottom',
isAnchorPoint: true,
});
}
},
getPath(cfg: NodeConfig): Array<Array<string | number>> {
const size = this.getSize!(cfg);
const width = size[0];
const height = size[1];
const path = [
['M', 0, -height / 2], // 上部顶点
['L', width / 2, 0], // 右侧点
['L', 0, height / 2], // 下部
['L', -width / 2, 0], // 左侧
['Z'], // 封闭
];
return path;
},
/**
* 使
* @param {Object} cfg
* @return {Object}
*/
getShapeStyle(cfg: NodeConfig): ShapeStyle {
const { style: defaultStyle } = this.getOptions(cfg) as NodeConfig;
const strokeStyle: ShapeStyle = {
stroke: cfg.color,
};
// 如果设置了color则覆盖默认的stroke属性
const style = mix({}, defaultStyle, strokeStyle);
const path = (this as any).getPath(cfg);
const styles = { path, ...style };
return styles;
},
update(cfg: NodeConfig, item: Item) {
const group = item.getContainer();
// 这里不传 cfg 参数是因为 cfg.style 需要最后覆盖样式
const { style: defaultStyle } = this.getOptions({}) as NodeConfig;
const path = (this as any).getPath(cfg);
// 下面这些属性需要覆盖默认样式与目前样式,但若在 cfg 中有指定则应该被 cfg 的相应配置覆盖。
const strokeStyle = {
stroke: cfg.color,
path,
};
// 与 getShapeStyle 不同在于update 时需要获取到当前的 style 进行融合。即新传入的配置项中没有涉及的属性,保留当前的配置。
const keyShape = item.get('keyShape');
let style = mix({}, defaultStyle, keyShape.attr(), strokeStyle);
style = mix(style, cfg.style);
(this as any).updateShape(cfg, item, style, true);
(this as any).updateLinkPoints(cfg, group);
},
},
'single-node',
);

View File

@ -0,0 +1,207 @@
import { Group as GGroup } from '@antv/g-canvas';
import { IShape } from '@antv/g-canvas/lib/interfaces';
import { mix } from '@antv/util';
import { Item, NodeConfig, ShapeStyle } from '../../types';
import Global from '../../global';
import Shape from '../shape';
/**
*
*/
Shape.registerNode(
'ellipse',
{
// 自定义节点时的配置
options: {
size: [80, 40],
style: {
x: 0,
y: 0,
stroke: Global.defaultNode.style.stroke,
fill: Global.defaultNode.style.fill,
lineWidth: Global.defaultNode.style.lineWidth
},
// 文本样式配置
labelCfg: {
style: {
fill: Global.nodeLabel.style.fill,
fontSize: Global.nodeLabel.style.fontSize,
},
},
// 节点上左右上下四个方向上的链接circle配置
linkPoints: {
top: false,
right: false,
bottom: false,
left: false,
// circle的大小
size: Global.defaultNode.linkPoints.size,
lineWidth: Global.defaultNode.linkPoints.lineWidth,
fill: Global.defaultNode.linkPoints.fill,
stroke: Global.defaultNode.linkPoints.stroke,
},
// 节点中icon配置
icon: {
// 是否显示icon值为 false 则不渲染icon
show: false,
// icon的地址字符串类型
img:
'https://gw.alipayobjects.com/zos/bmw-prod/5d015065-8505-4e7a-baec-976f81e3c41d.svg',
width: 20,
height: 20,
},
stateStyles: {
...Global.nodeStateStyles
}
},
shapeType: 'ellipse',
// 文本位置
labelPosition: 'center',
drawShape(cfg: NodeConfig, group: GGroup): IShape {
const { icon = {} } = this.getOptions(cfg) as NodeConfig;
const style = this.getShapeStyle!(cfg);
const keyShape = group.addShape('ellipse', {
attrs: style,
className: 'ellipse-keyShape',
name: 'ellipse-keyShape',
draggable: true,
});
const { width, height, show } = icon;
if (show) {
const image = group.addShape('image', {
attrs: {
x: -width! / 2,
y: -height! / 2,
...icon,
},
className: `${this.type}-icon`,
name: `${this.type}-icon`,
draggable: true,
});
}
(this as any).drawLinkPoints(cfg, group);
return keyShape;
},
/**
* LinkPoints
* @param {Object} cfg data数据配置项
* @param {Group} group Group实例
*/
drawLinkPoints(cfg: NodeConfig, group: GGroup) {
const { linkPoints = {} } = this.getOptions(cfg) as NodeConfig;
const { top, left, right, bottom, size: markSize, r: markR, ...markStyle } = linkPoints;
const size = this.getSize!(cfg);
const rx = size[0] / 2;
const ry = size[1] / 2;
if (left) {
// left circle
group.addShape('circle', {
attrs: {
...markStyle,
x: -rx,
y: 0,
r: markSize / 2 || markR || 5,
},
className: 'link-point-left',
name: 'link-point-left',
isAnchorPoint: true,
});
}
if (right) {
// right circle
group.addShape('circle', {
attrs: {
...markStyle,
x: rx,
y: 0,
r: markSize / 2 || markR || 5,
},
className: 'link-point-right',
name: 'link-point-right',
isAnchorPoint: true,
});
}
if (top) {
// top circle
group.addShape('circle', {
attrs: {
...markStyle,
x: 0,
y: -ry,
r: markSize / 2 || markR || 5,
},
className: 'link-point-top',
name: 'link-point-top',
isAnchorPoint: true,
});
}
if (bottom) {
// bottom circle
group.addShape('circle', {
attrs: {
...markStyle,
x: 0,
y: ry,
r: markSize / 2 || markR || 5,
},
className: 'link-point-bottom',
name: 'link-point-bottom',
isAnchorPoint: true,
});
}
},
/**
* 使
* @param {Object} cfg
* @return {Object}
*/
getShapeStyle(cfg: NodeConfig): ShapeStyle {
const { style: defaultStyle } = this.getOptions(cfg) as NodeConfig;
const strokeStyle: ShapeStyle = {
stroke: cfg.color,
};
// 如果设置了color则覆盖默认的stroke属性
const style = mix({}, defaultStyle, strokeStyle);
const size = this.getSize!(cfg);
const rx = size[0] / 2;
const ry = size[1] / 2;
const styles = {
x: 0,
y: 0,
rx,
ry,
...style,
};
return styles;
},
update(cfg: NodeConfig, item: Item) {
const group = item.getContainer();
// 这里不传 cfg 参数是因为 cfg.style 需要最后覆盖样式
const { style: defaultStyle } = this.getOptions({}) as NodeConfig;
const size = this.getSize!(cfg);
const strokeStyle = {
stroke: cfg.color,
rx: size[0] / 2,
ry: size[1] / 2,
};
// 与 getShapeStyle 不同在于update 时需要获取到当前的 style 进行融合。即新传入的配置项中没有涉及的属性,保留当前的配置。
const keyShape = item.get('keyShape');
let style = mix({}, defaultStyle, keyShape.attr(), strokeStyle);
style = mix(style, cfg.style);
(this as any).updateShape(cfg, item, style, true);
(this as any).updateLinkPoints(cfg, group);
},
},
'single-node',
);

View File

@ -0,0 +1,163 @@
import Shape from '../shape';
import { NodeConfig, Item } from '../../types';
import { Group as GGroup } from '@antv/g-canvas';
import { IShape } from '@antv/g-canvas/lib/interfaces';
/**
*
*/
Shape.registerNode(
'image',
{
options: {
img: 'https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*eD7nT6tmYgAAAAAAAAAAAABkARQnAQ',
size: 200,
clipCfg: {
show: false,
type: 'circle',
// circle
r: 50,
// ellipse
rx: 50,
ry: 35,
// rect
width: 50,
height: 35,
// polygon
points: [
[30, 12],
[12, 30],
[30, 48],
[48, 30],
],
// path
path: [
['M', 25, 25],
['L', 50, 25],
['A', 12.5, 12.5, 0, 1, 1, 50, 50],
['A', 12.5, 12.5, 0, 1, 0, 50, 50],
['L', 25, 75],
['Z'],
],
// 坐标
x: 0,
y: 0,
// clip 的属性样式
// style: {
// lineWidth: 1
// },
},
},
shapeType: 'image',
labelPosition: 'bottom',
drawShape(cfg: NodeConfig, group: GGroup): IShape {
const { shapeType } = this; // || this.type都已经加了 shapeType
const style = this.getShapeStyle!(cfg);
delete style.fill;
const shape = group.addShape(shapeType, {
attrs: style,
className: `${this.type}-keyShape`,
name: `${this.type}-keyShape`,
draggable: true,
});
(this as any).drawClip(cfg, shape);
return shape;
},
drawClip(cfg: NodeConfig, shape: IShape) {
const { clipCfg: clip } = this.getOptions(cfg);
if (!clip.show) {
return;
}
// 支持 circle、rect、ellipse、Polygon 及自定义 path clip
const { type, x, y, style } = clip;
if (type === 'circle') {
const { r } = clip;
shape.setClip({
type: 'circle',
attrs: {
r,
x,
y,
...style,
},
});
} else if (type === 'rect') {
const { width, height } = clip;
const rectX = x - width / 2;
const rectY = y - height / 2;
shape.setClip({
type: 'rect',
attrs: {
x: rectX,
y: rectY,
width,
height,
...style,
},
});
} else if (type === 'ellipse') {
const { rx, ry } = clip;
shape.setClip({
type: 'ellipse',
attrs: {
x,
y,
rx,
ry,
...style,
},
});
} else if (type === 'polygon') {
const { points } = clip;
shape.setClip({
type: 'polygon',
attrs: {
points,
...style,
},
});
} else if (type === 'path') {
const { path } = clip;
shape.setClip({
type: 'path',
attrs: {
path,
...style,
},
});
}
},
getShapeStyle(cfg: NodeConfig) {
const { style: defaultStyle } = this.getOptions(cfg);
const size = this.getSize!(cfg);
const { img } = this.getOptions(cfg);
let width = size[0];
let height = size[1];
if (defaultStyle) {
width = defaultStyle.width || size[0];
height = defaultStyle.height || size[1];
}
const style = {
x: -width / 2, // 节点的位置在上层确定,所以这里仅使用相对位置即可
y: -height / 2,
width,
height,
img,
...defaultStyle,
};
return style;
},
updateShapeStyle(cfg: NodeConfig, item: Item) {
const group = item.getContainer();
const shapeClassName = `${this.itemType}-shape`;
const shape =
group.find((element) => element.get('className') === shapeClassName) || item.getKeyShape();
const shapeStyle = this.getShapeStyle!(cfg);
if (shape) {
shape.attr(shapeStyle);
}
},
},
'single-node',
);

View File

@ -0,0 +1,8 @@
import './circle';
import './rect';
import './ellipse';
import './diamond';
import './triangle';
import './modelRect';
import './star';
import './image';

View File

@ -0,0 +1,481 @@
import { Group as GGroup } from '@antv/g-canvas';
import { IShape } from '@antv/g-canvas/lib/interfaces';
import { mix, isString } from '@antv/util';
import { Item, NodeConfig, ShapeStyle } from '../../types';
import Shape from '../shape';
import Global from '../../global';
import { ShapeOptions } from '../../interface/shape';
Shape.registerNode(
'modelRect',
{
// 自定义节点时的配置
options: {
size: [185, 70],
style: {
radius: 5,
stroke: '#69c0ff',
fill: '#ffffff',
lineWidth: Global.defaultNode.style.lineWidth,
fillOpacity: 1,
},
// 文本样式配置
labelCfg: {
style: {
fill: '#595959',
fontSize: 14,
},
offset: 30, // 距离左侧的 offset没有设置 y 轴上移动的配置
},
descriptionCfg: {
style: {
fontSize: 12,
fill: '#bfbfbf',
},
paddingTop: 0,
},
preRect: {
show: true,
width: 4,
fill: '#40a9ff',
radius: 2,
},
// 节点上左右上下四个方向上的链接circle配置
linkPoints: {
top: false,
right: false,
bottom: false,
left: false,
// circle的大小
size: 10,
lineWidth: 1,
fill: '#72CC4A',
stroke: '#72CC4A',
},
// 节点中icon配置
logoIcon: {
// 是否显示icon值为 false 则不渲染icon
show: true,
x: 0,
y: 0,
// icon的地址字符串类型
img:
'https://gw.alipayobjects.com/zos/basement_prod/4f81893c-1806-4de4-aff3-9a6b266bc8a2.svg',
width: 16,
height: 16,
// 用于调整图标的左右位置
offset: 0,
},
// 节点中表示状态的icon配置
stateIcon: {
// 是否显示icon值为 false 则不渲染icon
show: true,
x: 0,
y: 0,
// icon的地址字符串类型
img:
'https://gw.alipayobjects.com/zos/basement_prod/300a2523-67e0-4cbf-9d4a-67c077b40395.svg',
width: 16,
height: 16,
// 用于调整图标的左右位置
offset: -5,
},
// 连接点,默认为左右
// anchorPoints: [{ x: 0, y: 0.5 }, { x: 1, y: 0.5 }]
anchorPoints: [
[0, 0.5],
[1, 0.5],
],
},
shapeType: 'modelRect',
drawShape(cfg: NodeConfig, group: GGroup): IShape {
const { preRect = {} } = this.getOptions(cfg) as NodeConfig;
const style = this.getShapeStyle!(cfg);
const size = (this as ShapeOptions).getSize!(cfg);
const width = size[0];
const height = size[1];
const keyShape = group.addShape('rect', {
attrs: style,
className: `${this.type}-keyShape`,
name: `${this.type}-keyShape`,
draggable: true,
});
const { show: preRectShow, ...preRectStyle } = preRect;
if (preRectShow) {
group.addShape('rect', {
attrs: {
x: -width / 2,
y: -height / 2,
height,
...preRectStyle,
},
className: 'pre-rect',
name: 'pre-rect',
draggable: true,
});
}
(this as any).drawLogoIcon(cfg, group);
(this as any).drawStateIcon(cfg, group);
(this as any).drawLinkPoints(cfg, group);
return keyShape;
},
/**
* logo图标
* @param {Object} cfg
* @param {Group} group Group实例
*/
drawLogoIcon(cfg: NodeConfig, group: GGroup) {
const { logoIcon = {} } = this.getOptions(cfg) as NodeConfig;
const size = (this as ShapeOptions).getSize!(cfg);
const width = size[0];
if (logoIcon.show) {
const { width: w, height: h, x, y, offset, ...logoIconStyle } = logoIcon;
group.addShape('image', {
attrs: {
...logoIconStyle,
x: x || -width / 2 + (w as number) + (offset as number),
y: y || -(h as number) / 2,
width: w,
height: h,
},
className: 'rect-logo-icon',
name: 'rect-logo-icon',
draggable: true,
});
}
},
/**
*
* @param {Object} cfg
* @param {Group} group Group实例
*/
drawStateIcon(cfg: NodeConfig, group: GGroup) {
const { stateIcon = {} } = this.getOptions(cfg) as NodeConfig;
const size = (this as ShapeOptions).getSize!(cfg);
const width = size[0];
if (stateIcon.show) {
const { width: w, height: h, x, y, offset, ...iconStyle } = stateIcon;
group.addShape('image', {
attrs: {
...iconStyle,
x: x || width / 2 - (w as number) + (offset as number),
y: y || -(h as number) / 2,
width: w,
height: h,
},
className: 'rect-state-icon',
name: 'rect-state-icon',
draggable: true,
});
}
},
/**
* LinkPoints
* @param {Object} cfg data数据配置项
* @param {Group} group Group实例
*/
drawLinkPoints(cfg: NodeConfig, group: GGroup) {
const { linkPoints = {} } = this.getOptions(cfg) as NodeConfig;
const { top, left, right, bottom, size: markSize, r: markR, ...markStyle } = linkPoints;
const size = (this as ShapeOptions).getSize!(cfg);
const width = size[0];
const height = size[1];
if (left) {
// left circle
group.addShape('circle', {
attrs: {
...markStyle,
x: -width / 2,
y: 0,
r: markSize / 2 || markR || 5,
},
className: 'link-point-left',
name: 'link-point-left',
isAnchorPoint: true,
});
}
if (right) {
// right circle
group.addShape('circle', {
attrs: {
...markStyle,
x: width / 2,
y: 0,
r: markSize / 2 || markR || 5,
},
className: 'link-point-right',
name: 'link-point-right',
isAnchorPoint: true,
});
}
if (top) {
// top circle
group.addShape('circle', {
attrs: {
...markStyle,
x: 0,
y: -height / 2,
r: markSize / 2 || markR || 5,
},
className: 'link-point-top',
name: 'link-point-top',
isAnchorPoint: true,
});
}
if (bottom) {
// bottom circle
group.addShape('circle', {
attrs: {
...markStyle,
x: 0,
y: height / 2,
r: markSize / 2 || markR || 5,
},
className: 'link-point-bottom',
name: 'link-point-bottom',
isAnchorPoint: true,
});
}
},
drawLabel(cfg: NodeConfig, group: GGroup): IShape {
const { labelCfg = {}, logoIcon = {}, descriptionCfg = {} } = this.getOptions(cfg) as NodeConfig;
const size = (this as ShapeOptions).getSize!(cfg);
const width = size[0];
let label = null;
const { show, width: w } = logoIcon;
let offsetX = -width / 2 + labelCfg.offset;
if (show) {
offsetX = -width / 2 + (w as number) + labelCfg.offset;
}
const { style: fontStyle } = labelCfg;
const { style: descriptionStyle, paddingTop: descriptionPaddingTop } = descriptionCfg;
if (isString(cfg.description)) {
label = group.addShape('text', {
attrs: {
...fontStyle,
x: offsetX,
y: -5,
text: cfg.label,
},
className: 'text-shape',
name: 'text-shape',
draggable: true,
});
group.addShape('text', {
attrs: {
...descriptionStyle,
x: offsetX,
y: 17 + (descriptionPaddingTop as any || 0),
text: cfg.description,
},
className: 'rect-description',
name: 'rect-description',
draggable: true,
});
} else {
label = group.addShape('text', {
attrs: {
...fontStyle,
x: offsetX,
y: 7,
text: cfg.label,
},
draggable: true,
});
}
return label;
},
/**
* 使
* @param {Object} cfg
* @return {Object}
*/
getShapeStyle(cfg: NodeConfig) {
const { style: defaultStyle } = this.getOptions(cfg) as NodeConfig;
const strokeStyle: ShapeStyle = {
stroke: cfg.color,
};
// 如果设置了color则覆盖默认的stroke属性
const style: ShapeStyle = mix({}, defaultStyle, strokeStyle);
const size = (this as ShapeOptions).getSize!(cfg);
const width = style.width || size[0];
const height = style.height || size[1];
const styles = {
x: -width / 2,
y: -height / 2,
width,
height,
...style,
};
return styles;
},
update(cfg: NodeConfig, item: Item) {
const { style = {}, labelCfg = {}, descriptionCfg = {} } = this.getOptions(cfg) as NodeConfig;
const size = (this as ShapeOptions).getSize!(cfg);
const width = size[0];
const height = size[1];
const keyShape = item.get('keyShape');
keyShape.attr({
...style,
x: -width / 2,
y: -height / 2,
width,
height,
});
const group = item.getContainer();
const logoIconShape = group.find((element) => element.get('className') === 'rect-logo-icon');
const currentLogoIconAttr = logoIconShape ? logoIconShape.attr() : {};
const logoIcon = mix({}, currentLogoIconAttr, cfg.logoIcon);
let { width: w } = logoIcon;
if (w === undefined) {
w = (this as any).options.logoIcon.width;
}
const show = cfg.logoIcon ? cfg.logoIcon.show : undefined;
const { offset } = labelCfg;
let offsetX = -width / 2 + w + offset;
if (!show && show !== undefined) {
offsetX = -width / 2 + offset;
}
const label = group.find((element) => element.get('className') === 'node-label');
const description = group.find((element) => element.get('className') === 'rect-description');
if (cfg.label) {
if (!label) {
group.addShape('text', {
attrs: {
...labelCfg.style,
x: offsetX,
y: cfg.description ? -5 : 7,
text: cfg.label,
},
className: 'node-label',
name: 'node-label',
draggable: true,
});
} else {
const cfgStyle = cfg.labelCfg ? cfg.labelCfg.style : {};
const labelStyle = mix({}, label.attr(), cfgStyle);
if (cfg.label) labelStyle.text = cfg.label;
labelStyle.x = offsetX;
if (isString(cfg.description)) labelStyle.y = -5;
if (description) {
description.resetMatrix();
description.attr({
x: offsetX,
});
}
label.resetMatrix();
label.attr(labelStyle);
}
}
if (isString(cfg.description)) {
const { paddingTop } = descriptionCfg;
if (!description) {
group.addShape('text', {
attrs: {
...descriptionCfg.style,
x: offsetX,
y: 17 + (paddingTop as any || 0),
text: cfg.description,
},
className: 'rect-description',
name: 'rect-description',
draggable: true,
});
} else {
const cfgStyle = cfg.descriptionCfg ? cfg.descriptionCfg.style : {};
const descriptionStyle = mix({}, description.attr(), cfgStyle);
if (isString(cfg.description)) descriptionStyle.text = cfg.description;
descriptionStyle.x = offsetX;
description.resetMatrix();
description.attr({
...descriptionStyle,
y: 17 + (paddingTop as any || 0),
});
}
}
const preRectShape = group.find((element) => element.get('className') === 'pre-rect');
if (preRectShape) {
const preRect = mix({}, preRectShape.attr(), cfg.preRect);
preRectShape.attr({
...preRect,
x: -width / 2,
y: -height / 2,
height,
});
}
if (logoIconShape) {
if (!show && show !== undefined) {
logoIconShape.remove();
} else {
const { width: logoW, height: h, x, y, offset: logoOffset, ...logoIconStyle } = logoIcon;
logoIconShape.attr({
...logoIconStyle,
x: x || -width / 2 + logoW + logoOffset,
y: y || -h / 2,
width: logoW,
height: h,
});
}
} else if (show) {
(this as any).drawLogoIcon(cfg, group);
}
const stateIconShape = group.find(
(element) => element.get('className') === 'rect-state-icon',
);
const currentStateIconAttr = stateIconShape ? stateIconShape.attr() : {};
const stateIcon = mix({}, currentStateIconAttr, cfg.stateIcon);
if (stateIconShape) {
if (!stateIcon.show && stateIcon.show !== undefined) {
stateIconShape.remove();
}
const {
width: stateW,
height: h,
x,
y,
offset: stateOffset,
...stateIconStyle
} = stateIcon;
stateIconShape.attr({
...stateIconStyle,
x: x || width / 2 - stateW + stateOffset,
y: y || -h / 2,
width: stateW,
height: h,
});
} else if (stateIcon.show) {
(this as any).drawStateIcon(cfg, group);
}
(this as any).updateLinkPoints(cfg, group);
},
},
'single-node',
);

View File

@ -0,0 +1,199 @@
import { Group as GGroup } from '@antv/g-canvas';
import { IShape } from '@antv/g-canvas/lib/interfaces';
import { mix } from '@antv/util';
import { Item, NodeConfig, ShapeStyle } from '../../types';
import Global from '../../global';
import Shape from '../shape';
import { ShapeOptions } from '../../interface/shape';
Shape.registerNode(
'rect',
{
// 自定义节点时的配置
options: {
size: [100, 30],
style: {
radius: 0,
stroke: Global.defaultNode.style.stroke,
fill: Global.defaultNode.style.fill,
lineWidth: Global.defaultNode.style.lineWidth
},
// 文本样式配置
labelCfg: {
style: {
fill: Global.nodeLabel.style.fill,
fontSize: Global.nodeLabel.style.fontSize,
},
},
// 节点上左右上下四个方向上的链接circle配置
linkPoints: {
top: false,
right: false,
bottom: false,
left: false,
// circle的大小
size: Global.defaultNode.linkPoints.size,
lineWidth: Global.defaultNode.linkPoints.lineWidth,
fill: Global.defaultNode.linkPoints.fill,
stroke: Global.defaultNode.linkPoints.stroke,
},
// 节点中icon配置
icon: {
// 是否显示icon值为 false 则不渲染icon
show: false,
// icon的地址字符串类型
img:
'https://gw.alipayobjects.com/zos/bmw-prod/5d015065-8505-4e7a-baec-976f81e3c41d.svg',
width: 20,
height: 20,
},
// 连接点,默认为左右
// anchorPoints: [{ x: 0, y: 0.5 }, { x: 1, y: 0.5 }]
anchorPoints: [
[0, 0.5],
[1, 0.5],
],
stateStyles: {
...Global.nodeStateStyles
}
},
shapeType: 'rect',
labelPosition: 'center',
drawShape(cfg: NodeConfig, group: GGroup): IShape {
const style = this.getShapeStyle!(cfg);
const keyShape = group.addShape('rect', {
attrs: style,
className: `${this.type}-keyShape`,
name: `${this.type}-keyShape`,
draggable: true,
});
(this as any).drawLinkPoints(cfg, group);
return keyShape;
},
/**
* LinkPoints
* @param {Object} cfg data数据配置项
* @param {Group} group Group实例
*/
drawLinkPoints(cfg: NodeConfig, group: GGroup) {
const { linkPoints = {} } = this.getOptions(cfg) as NodeConfig;
const { top, left, right, bottom, size: markSize, r: markR, ...markStyle } = linkPoints;
const size = (this as ShapeOptions).getSize!(cfg);
const width = size[0];
const height = size[1];
if (left) {
// left circle
group.addShape('circle', {
attrs: {
...markStyle,
x: -width / 2,
y: 0,
r: markSize / 2 || markR || 5,
},
className: 'link-point-left',
name: 'link-point-left',
isAnchorPoint: true,
});
}
if (right) {
// right circle
group.addShape('circle', {
attrs: {
...markStyle,
x: width / 2,
y: 0,
r: markSize / 2 || markR || 5,
},
className: 'link-point-right',
name: 'link-point-right',
isAnchorPoint: true,
});
}
if (top) {
// top circle
group.addShape('circle', {
attrs: {
...markStyle,
x: 0,
y: -height / 2,
r: markSize / 2 || markR || 5,
},
className: 'link-point-top',
name: 'link-point-top',
isAnchorPoint: true,
});
}
if (bottom) {
// bottom circle
group.addShape('circle', {
attrs: {
...markStyle,
x: 0,
y: height / 2,
r: markSize / 2 || markR || 5,
},
className: 'link-point-bottom',
name: 'link-point-bottom',
isAnchorPoint: true,
});
}
},
/**
* 使
* @param {Object} cfg
* @return {Object}
*/
getShapeStyle(cfg: NodeConfig) {
const { style: defaultStyle } = this.getOptions(cfg) as NodeConfig;
const strokeStyle: ShapeStyle = {
stroke: cfg.color,
};
// 如果设置了color则覆盖默认的stroke属性
const style = mix({}, defaultStyle, strokeStyle);
const size = (this as ShapeOptions).getSize!(cfg);
const width = style.width || size[0];
const height = style.height || size[1];
const styles = {
x: -width / 2,
y: -height / 2,
width,
height,
...style,
};
return styles;
},
update(cfg: NodeConfig, item: Item) {
const group = item.getContainer();
// 这里不传 cfg 参数是因为 cfg.style 需要最后覆盖样式
const { style: defaultStyle } = this.getOptions({}) as NodeConfig;
const size = (this as ShapeOptions).getSize!(cfg);
const keyShape = item.get('keyShape');
if (!cfg.size) {
size[0] = keyShape.attr('width') || defaultStyle.width;
size[1] = keyShape.attr('height') || defaultStyle.height;
}
// 下面这些属性需要覆盖默认样式与目前样式,但若在 cfg 中有指定则应该被 cfg 的相应配置覆盖。
const strokeStyle = {
stroke: cfg.color,
x: -size[0] / 2,
y: -size[1] / 2,
width: size[0],
height: size[1],
};
// 与 getShapeStyle 不同在于update 时需要获取到当前的 style 进行融合。即新传入的配置项中没有涉及的属性,保留当前的配置。
let style = mix({}, defaultStyle, keyShape.attr(), strokeStyle);
style = mix(style, cfg.style);
(this as any).updateShape(cfg, item, style, false);
(this as any).updateLinkPoints(cfg, group);
},
},
'single-node',
);

View File

@ -0,0 +1,429 @@
import { Group as GGroup } from '@antv/g-canvas';
import { IShape } from '@antv/g-canvas/lib/interfaces';
import { mix } from '@antv/util';
import { Item, NodeConfig, ShapeStyle } from '../../types';
import Global from '../../global';
import Shape from '../shape';
import { ShapeOptions } from '../../interface/shape';
// 五角星shape
Shape.registerNode(
'star',
{
// 自定义节点时的配置
options: {
size: 60,
style: {
stroke: Global.defaultNode.style.stroke,
fill: Global.defaultNode.style.fill,
lineWidth: Global.defaultNode.style.lineWidth
},
labelCfg: {
style: {
fill: Global.nodeLabel.style.fill,
fontSize: Global.nodeLabel.style.fontSize,
},
},
// 节点上左右上下四个方向上的链接circle配置
linkPoints: {
top: false,
right: false,
bottom: false,
left: false,
// circle的大小
size: Global.defaultNode.linkPoints.size,
lineWidth: Global.defaultNode.linkPoints.lineWidth,
fill: Global.defaultNode.linkPoints.fill,
stroke: Global.defaultNode.linkPoints.stroke,
},
// 节点中icon配置
icon: {
// 是否显示icon值为 false 则不渲染icon
show: false,
// icon的地址字符串类型
img:
'https://gw.alipayobjects.com/zos/bmw-prod/5d015065-8505-4e7a-baec-976f81e3c41d.svg',
width: 20,
height: 20,
},
stateStyles: {
...Global.nodeStateStyles
}
},
shapeType: 'star',
// 文本位置
labelPosition: 'center',
drawShape(cfg: NodeConfig, group: GGroup): IShape {
const { icon = {} } = this.getOptions(cfg) as NodeConfig;
const style = this.getShapeStyle!(cfg);
const keyShape = group.addShape('path', {
attrs: style,
className: `${this.type}-keyShape`,
name: `${this.type}-keyShape`,
draggable: true,
});
const { width: w, height: h, show } = icon;
if (show) {
const image = group.addShape('image', {
attrs: {
x: -w! / 2,
y: -h! / 2,
...icon,
},
className: `${this.type}-icon`,
name: `${this.type}-icon`,
draggable: true,
});
}
(this as any).drawLinkPoints(cfg, group);
return keyShape;
},
/**
* LinkPoints
* @param {Object} cfg data数据配置项
* @param {Group} group Group实例
*/
drawLinkPoints(cfg: NodeConfig, group: GGroup) {
const { linkPoints = {} } = this.getOptions(cfg) as NodeConfig;
const {
top,
left,
right,
leftBottom,
rightBottom,
size: markSize,
r: markR,
...markStyle
} = linkPoints;
const size = (this as ShapeOptions).getSize!(cfg);
const outerR = size[0];
if (right) {
// right circle
// up down left right 四个方向的坐标均不相同
const x1 = Math.cos(((18 + 72 * 0) / 180) * Math.PI) * outerR;
const y1 = Math.sin(((18 + 72 * 0) / 180) * Math.PI) * outerR;
group.addShape('circle', {
attrs: {
...markStyle,
x: x1,
y: -y1,
r: markSize / 2 || markR || 5,
},
className: 'link-point-right',
name: 'link-point-right',
});
}
if (top) {
// up down left right 四个方向的坐标均不相同
const x1 = Math.cos(((18 + 72 * 1) / 180) * Math.PI) * outerR;
const y1 = Math.sin(((18 + 72 * 1) / 180) * Math.PI) * outerR;
// top circle
group.addShape('circle', {
attrs: {
...markStyle,
x: x1,
y: -y1,
r: markSize / 2 || markR || 5,
},
className: 'link-point-top',
name: 'link-point-top',
});
}
if (left) {
// up down left right 四个方向的坐标均不相同
const x1 = Math.cos(((18 + 72 * 2) / 180) * Math.PI) * outerR;
const y1 = Math.sin(((18 + 72 * 2) / 180) * Math.PI) * outerR;
// left circle
group.addShape('circle', {
attrs: {
...markStyle,
x: x1,
y: -y1,
r: markSize / 2 || markR || 5,
},
className: 'link-point-left',
name: 'link-point-left',
});
}
if (leftBottom) {
// up down left right 四个方向的坐标均不相同
const x1 = Math.cos(((18 + 72 * 3) / 180) * Math.PI) * outerR;
const y1 = Math.sin(((18 + 72 * 3) / 180) * Math.PI) * outerR;
// left bottom circle
group.addShape('circle', {
attrs: {
...markStyle,
x: x1,
y: -y1,
r: markSize / 2 || markR || 5,
},
className: 'link-point-left-bottom',
name: 'link-point-left-bottom',
});
}
if (rightBottom) {
// up down left right 四个方向的坐标均不相同
const x1 = Math.cos(((18 + 72 * 4) / 180) * Math.PI) * outerR;
const y1 = Math.sin(((18 + 72 * 4) / 180) * Math.PI) * outerR;
// left bottom circle
group.addShape('circle', {
attrs: {
...markStyle,
x: x1,
y: -y1,
r: markSize / 2 || markR || 5,
},
className: 'link-point-right-bottom',
name: 'link-point-right-bottom',
});
}
},
getPath(cfg: NodeConfig) {
const size = (this as ShapeOptions).getSize!(cfg);
const outerR = size[0];
const defaultInnerR = (outerR * 3) / 8;
const innerR = cfg.innerR || defaultInnerR;
const path = [];
for (let i = 0; i < 5; i++) {
const x1 = Math.cos(((18 + 72 * i) / 180) * Math.PI) * outerR;
const y1 = Math.sin(((18 + 72 * i) / 180) * Math.PI) * outerR;
const x2 = Math.cos(((54 + 72 * i) / 180) * Math.PI) * innerR;
const y2 = Math.sin(((54 + 72 * i) / 180) * Math.PI) * innerR;
if (i === 0) {
path.push(['M', x1, -y1]);
} else {
path.push(['L', x1, -y1]);
}
path.push(['L', x2, -y2]);
}
path.push(['Z']);
return path;
},
/**
* 使
* @param {Object} cfg
* @return {Object}
*/
getShapeStyle(cfg: NodeConfig): ShapeStyle {
const { style: defaultStyle } = this.getOptions(cfg) as NodeConfig;
const strokeStyle: ShapeStyle = {
stroke: cfg.color,
};
// 如果设置了color则覆盖原来默认的 stroke 属性。但 cfg 中但 stroke 属性优先级更高
const style = mix({}, defaultStyle, strokeStyle);
const path = (this as any).getPath(cfg);
const styles = { path, ...style };
return styles;
},
update(cfg: NodeConfig, item: Item) {
const group = item.getContainer();
// 这里不传 cfg 参数是因为 cfg.style 需要最后覆盖样式
const { style: defaultStyle } = this.getOptions({}) as NodeConfig;
const path = (this as any).getPath(cfg);
// 下面这些属性需要覆盖默认样式与目前样式,但若在 cfg 中有指定则应该被 cfg 的相应配置覆盖。
const strokeStyle = {
stroke: cfg.color,
path,
};
// 与 getShapeStyle 不同在于update 时需要获取到当前的 style 进行融合。即新传入的配置项中没有涉及的属性,保留当前的配置。
const keyShape = item.get('keyShape');
let style = mix({}, defaultStyle, keyShape.attr(), strokeStyle);
style = mix(style, cfg.style);
(this as any).updateShape(cfg, item, style, true);
(this as any).updateLinkPoints(cfg, group);
},
/**
* linkPoints
* @param {Object} cfg
* @param {Group} group Item所在的group
*/
updateLinkPoints(cfg: NodeConfig, group: GGroup) {
const { linkPoints: defaultLinkPoints } = this.getOptions({}) as NodeConfig;
const markLeft = group.find((element) => element.get('className') === 'link-point-left');
const markRight = group.find((element) => element.get('className') === 'link-point-right');
const markTop = group.find((element) => element.get('className') === 'link-point-top');
const markLeftBottom = group.find(
(element) => element.get('className') === 'link-point-left-bottom',
);
const markRightBottom = group.find(
(element) => element.get('className') === 'link-point-right-bottom',
);
let currentLinkPoints = defaultLinkPoints;
const existLinkPoint = markLeft || markRight || markTop || markLeftBottom || markRightBottom;
if (existLinkPoint) {
currentLinkPoints = existLinkPoint.attr();
}
const linkPoints = mix({}, currentLinkPoints, cfg.linkPoints);
const { fill: markFill, stroke: markStroke, lineWidth: borderWidth } = linkPoints;
let markSize = linkPoints.size / 2;
if (!markSize) markSize = linkPoints.r;
const { left, right, top, leftBottom, rightBottom } = cfg.linkPoints
? cfg.linkPoints
: {
left: undefined,
right: undefined,
top: undefined,
leftBottom: undefined,
rightBottom: undefined,
};
const size = (this as ShapeOptions).getSize!(cfg);
const outerR = size[0];
const styles = {
r: markSize,
fill: markFill,
stroke: markStroke,
lineWidth: borderWidth,
};
let x = Math.cos(((18 + 72 * 0) / 180) * Math.PI) * outerR;
let y = Math.sin(((18 + 72 * 0) / 180) * Math.PI) * outerR;
if (markRight) {
if (!right && right !== undefined) {
markRight.remove();
} else {
markRight.attr({
...styles,
x,
y: -y,
});
}
} else if (right) {
group.addShape('circle', {
attrs: {
...styles,
x,
y: -y,
},
className: 'link-point-right',
name: 'link-point-right',
isAnchorPoint: true,
});
}
x = Math.cos(((18 + 72 * 1) / 180) * Math.PI) * outerR;
y = Math.sin(((18 + 72 * 1) / 180) * Math.PI) * outerR;
if (markTop) {
if (!top && top !== undefined) {
markTop.remove();
} else {
markTop.attr({
...styles,
x,
y: -y,
});
}
} else if (top) {
group.addShape('circle', {
attrs: {
...styles,
x,
y: -y,
},
className: 'link-point-top',
name: 'link-point-top',
isAnchorPoint: true,
});
}
x = Math.cos(((18 + 72 * 2) / 180) * Math.PI) * outerR;
y = Math.sin(((18 + 72 * 2) / 180) * Math.PI) * outerR;
if (markLeft) {
if (!left && left !== undefined) {
markLeft.remove();
} else {
markLeft.attr({
...styles,
x,
y: -y,
});
}
} else if (left) {
group.addShape('circle', {
attrs: {
...styles,
x,
y: -y,
},
className: 'link-point-left',
name: 'link-point-left',
isAnchorPoint: true,
});
}
x = Math.cos(((18 + 72 * 3) / 180) * Math.PI) * outerR;
y = Math.sin(((18 + 72 * 3) / 180) * Math.PI) * outerR;
if (markLeftBottom) {
if (!leftBottom && leftBottom !== undefined) {
markLeftBottom.remove();
} else {
markLeftBottom.attr({
...styles,
x,
y: -y,
});
}
} else if (leftBottom) {
group.addShape('circle', {
attrs: {
...styles,
x,
y: -y,
},
className: 'link-point-left-bottom',
name: 'link-point-left-bottom',
isAnchorPoint: true,
});
}
x = Math.cos(((18 + 72 * 4) / 180) * Math.PI) * outerR;
y = Math.sin(((18 + 72 * 4) / 180) * Math.PI) * outerR;
if (markRightBottom) {
if (!rightBottom && rightBottom !== undefined) {
markLeftBottom.remove();
} else {
markRightBottom.attr({
...styles,
x,
y: -y,
});
}
} else if (rightBottom) {
group.addShape('circle', {
attrs: {
...styles,
x,
y: -y,
},
className: 'link-point-right-bottom',
name: 'link-point-right-bottom',
isAnchorPoint: true,
});
}
},
},
'single-node',
);

View File

@ -0,0 +1,476 @@
import Shape from '../shape';
import { mix } from '@antv/util';
import Global from '../../global';
import { Group as GGroup } from '@antv/g-canvas';
import { IShape } from '@antv/g-canvas/lib/interfaces';
import { ModelConfig, NodeConfig, Item, ShapeStyle } from '../../types';
import { ShapeOptions } from '../../interface/shape';
// 菱形shape
Shape.registerNode(
'triangle',
{
// 自定义节点时的配置
options: {
size: 40,
direction: 'up',
style: {
stroke: Global.defaultNode.style.stroke,
fill: Global.defaultNode.style.fill,
lineWidth: Global.defaultNode.style.lineWidth
},
labelCfg: {
style: {
fill: Global.nodeLabel.style.fill,
fontSize: Global.nodeLabel.style.fontSize,
},
offset: 15
},
// 节点上左右上下四个方向上的链接circle配置
linkPoints: {
top: false,
right: false,
bottom: false,
left: false,
// circle的大小
size: Global.defaultNode.linkPoints.size,
lineWidth: Global.defaultNode.linkPoints.lineWidth,
fill: Global.defaultNode.linkPoints.fill,
stroke: Global.defaultNode.linkPoints.stroke,
},
// 节点中icon配置
icon: {
// 是否显示icon值为 false 则不渲染icon
show: false,
// icon的地址字符串类型
img:
'https://gw.alipayobjects.com/zos/bmw-prod/5d015065-8505-4e7a-baec-976f81e3c41d.svg',
width: 20,
height: 20,
offset: 6
},
stateStyles: {
...Global.nodeStateStyles
}
},
shapeType: 'triangle',
// 文本位置
labelPosition: 'bottom',
drawShape(cfg: NodeConfig, group: GGroup): IShape {
const { icon = {}, direction: defaultDirection } = this.getOptions(cfg) as NodeConfig;
const style = this.getShapeStyle!(cfg);
const direction = cfg.direction || defaultDirection;
const keyShape = group.addShape('path', {
attrs: style,
className: `${this.type}-keyShape`,
name: `${this.type}-keyShape`,
draggable: true,
});
const { width: w, height: h, show, offset } = icon;
if (show) {
let iconW = -w! / 2;
let iconH = -h! / 2;
if (direction === 'up' || direction === 'down') {
iconH += offset;
}
if (direction === 'left' || direction === 'right') {
iconW += offset;
}
group.addShape('image', {
attrs: {
x: iconW,
y: iconH,
...icon,
},
className: `${this.type}-icon`,
name: `${this.type}-icon`,
draggable: true,
});
}
(this as any).drawLinkPoints(cfg, group);
return keyShape;
},
/**
* LinkPoints
* @param {Object} cfg data数据配置项
* @param {Group} group Group实例
*/
drawLinkPoints(cfg: NodeConfig, group: GGroup) {
const { linkPoints = {}, direction: defaultDirection } = this.getOptions(cfg) as NodeConfig;
const direction = cfg.direction || defaultDirection;
const { top, left, right, bottom, size: markSize, r: markR, ...markStyle } = linkPoints;
const size = (this as ShapeOptions).getSize!(cfg);
const len = size[0];
if (left) {
// up down left right 四个方向的坐标均不相同
let leftPos = null;
const diffY = len * Math.sin((1 / 3) * Math.PI);
const r = len * Math.sin((1 / 3) * Math.PI);
if (direction === 'up') {
leftPos = [-r, diffY];
} else if (direction === 'down') {
leftPos = [-r, -diffY];
} else if (direction === 'left') {
leftPos = [-r, r - diffY];
}
if (leftPos) {
// left circle
group.addShape('circle', {
attrs: {
...markStyle,
x: leftPos[0],
y: leftPos[1],
r: markSize / 2 || markR || 5,
},
className: 'link-point-left',
name: 'link-point-left',
});
}
}
if (right) {
// right circle
// up down left right 四个方向的坐标均不相同
let rightPos = null;
const diffY = len * Math.sin((1 / 3) * Math.PI);
const r = len * Math.sin((1 / 3) * Math.PI);
if (direction === 'up') {
rightPos = [r, diffY];
} else if (direction === 'down') {
rightPos = [r, -diffY];
} else if (direction === 'right') {
rightPos = [r, r - diffY];
}
if (rightPos) {
group.addShape('circle', {
attrs: {
...markStyle,
x: rightPos[0],
y: rightPos[1],
r: markSize / 2 || markR || 5,
},
className: 'link-point-right',
name: 'link-point-right',
});
}
}
if (top) {
// up down left right 四个方向的坐标均不相同
let topPos = null;
const diffY = len * Math.sin((1 / 3) * Math.PI);
const r = len * Math.sin((1 / 3) * Math.PI);
if (direction === 'up') {
topPos = [r - diffY, -diffY];
} else if (direction === 'left') {
topPos = [r, -diffY];
} else if (direction === 'right') {
topPos = [-r, -diffY];
}
if (topPos) {
// top circle
group.addShape('circle', {
attrs: {
...markStyle,
x: topPos[0],
y: topPos[1],
r: markSize / 2 || markR || 5,
},
className: 'link-point-top',
name: 'link-point-top',
});
}
}
if (bottom) {
// up down left right 四个方向的坐标均不相同
let bottomPos = null;
const diffY = len * Math.sin((1 / 3) * Math.PI);
const r = len * Math.sin((1 / 3) * Math.PI);
if (direction === 'down') {
bottomPos = [-r + diffY, diffY];
} else if (direction === 'left') {
bottomPos = [r, diffY];
} else if (direction === 'right') {
bottomPos = [-r, diffY];
}
if (bottomPos) {
// bottom circle
group.addShape('circle', {
attrs: {
...markStyle,
x: bottomPos[0],
y: bottomPos[1],
r: markSize / 2 || markR || 5,
},
className: 'link-point-bottom',
name: 'link-point-bottom',
});
}
}
},
getPath(cfg: ModelConfig) {
const { direction: defaultDirection } = this.getOptions(cfg) as NodeConfig;
const direction = cfg.direction || defaultDirection;
const size = (this as ShapeOptions).getSize!(cfg);
const len = size[0];
const diffY = len * Math.sin((1 / 3) * Math.PI);
const r = len * Math.sin((1 / 3) * Math.PI);
let path = [
['M', -r, diffY],
['L', 0, -diffY],
['L', r, diffY],
['Z'], // 封闭
];
if (direction === 'down') {
path = [
['M', -r, -diffY],
['L', r, -diffY],
['L', 0, diffY],
['Z'], // 封闭
];
} else if (direction === 'left') {
path = [
['M', -r, r - diffY],
['L', r, -r],
['L', r, r],
['Z'], // 封闭
];
} else if (direction === 'right') {
path = [
['M', r, r - diffY],
['L', -r, r],
['L', -r, -r],
['Z'], // 封闭
];
}
return path;
},
/**
* 使
* @param {Object} cfg
* @return {Object}
*/
getShapeStyle(cfg: NodeConfig) {
const { style: defaultStyle } = this.getOptions(cfg) as NodeConfig;
const strokeStyle: ShapeStyle = {
stroke: cfg.color,
};
// 如果设置了color则覆盖默认的stroke属性
const style = mix({}, defaultStyle, strokeStyle);
const path = (this as any).getPath(cfg);
const styles = { path, ...style };
return styles;
},
update(cfg: NodeConfig, item: Item) {
const group = item.getContainer();
// 这里不传 cfg 参数是因为 cfg.style 需要最后覆盖样式
const { style: defaultStyle } = this.getOptions({}) as NodeConfig;
const path = (this as any).getPath(cfg);
// 下面这些属性需要覆盖默认样式与目前样式,但若在 cfg 中有指定则应该被 cfg 的相应配置覆盖。
const strokeStyle = {
stroke: cfg.color,
path,
};
// 与 getShapeStyle 不同在于update 时需要获取到当前的 style 进行融合。即新传入的配置项中没有涉及的属性,保留当前的配置。
const keyShape = item.get('keyShape');
let style = mix({}, defaultStyle, keyShape.attr(), strokeStyle);
style = mix(style, cfg.style);
(this as any).updateShape(cfg, item, style, true);
(this as any).updateLinkPoints(cfg, group);
},
/**
* linkPoints
* @param {Object} cfg
* @param {Group} group Item所在的group
*/
updateLinkPoints(cfg: NodeConfig, group: GGroup) {
const { linkPoints: defaultLinkPoints, direction: defaultDirection } = this.getOptions(
{},
) as NodeConfig;
const direction = cfg.direction || defaultDirection;
const markLeft = group.find((element) => element.get('className') === 'link-point-left');
const markRight = group.find((element) => element.get('className') === 'link-point-right');
const markTop = group.find((element) => element.get('className') === 'link-point-top');
const markBottom = group.find((element) => element.get('className') === 'link-point-bottom');
let currentLinkPoints = defaultLinkPoints;
const existLinkPoint = markLeft || markRight || markTop || markBottom;
if (existLinkPoint) {
currentLinkPoints = existLinkPoint.attr();
}
const linkPoints = mix({}, currentLinkPoints, cfg.linkPoints);
const { fill: markFill, stroke: markStroke, lineWidth: borderWidth } = linkPoints;
let markSize = linkPoints.size / 2;
if (!markSize) markSize = linkPoints.r;
const { left, right, top, bottom } = cfg.linkPoints
? cfg.linkPoints
: { left: undefined, right: undefined, top: undefined, bottom: undefined };
const size = (this as ShapeOptions).getSize!(cfg);
const len = size[0];
const styles = {
r: markSize,
fill: markFill,
stroke: markStroke,
lineWidth: borderWidth,
};
let leftPos = null;
const diffY = len * Math.sin((1 / 3) * Math.PI);
const r = len * Math.sin((1 / 3) * Math.PI);
if (direction === 'up') {
leftPos = [-r, diffY];
} else if (direction === 'down') {
leftPos = [-r, -diffY];
} else if (direction === 'left') {
leftPos = [-r, r - diffY];
}
if (leftPos) {
if (markLeft) {
if (!left && left !== undefined) {
markLeft.remove();
} else {
markLeft.attr({
...styles,
x: leftPos[0],
y: leftPos[1],
});
}
} else if (left) {
group.addShape('circle', {
attrs: {
...styles,
x: leftPos[0],
y: leftPos[1],
},
className: 'link-point-left',
name: 'link-point-left',
isAnchorPoint: true,
});
}
}
let rightPos = null;
if (direction === 'up') {
rightPos = [r, diffY];
} else if (direction === 'down') {
rightPos = [r, -diffY];
} else if (direction === 'right') {
rightPos = [r, r - diffY];
}
if (rightPos) {
if (markRight) {
if (!right && right !== undefined) {
markRight.remove();
} else {
markRight.attr({
...styles,
x: rightPos[0],
y: rightPos[1],
});
}
} else if (right) {
group.addShape('circle', {
attrs: {
...styles,
x: rightPos[0],
y: rightPos[1],
},
className: 'link-point-right',
name: 'link-point-right',
isAnchorPoint: true,
});
}
}
let topPos = null;
if (direction === 'up') {
topPos = [r - diffY, -diffY];
} else if (direction === 'left') {
topPos = [r, -diffY];
} else if (direction === 'right') {
topPos = [-r, -diffY];
}
if (topPos) {
if (markTop) {
if (!top && top !== undefined) {
markTop.remove();
} else {
// top circle
markTop.attr({
...styles,
x: topPos[0],
y: topPos[1],
});
}
} else if (top) {
group.addShape('circle', {
attrs: {
...styles,
x: topPos[0],
y: topPos[1],
},
className: 'link-point-top',
name: 'link-point-top',
isAnchorPoint: true,
});
}
}
let bottomPos = null;
if (direction === 'down') {
bottomPos = [-r + diffY, diffY];
} else if (direction === 'left') {
bottomPos = [r, diffY];
} else if (direction === 'right') {
bottomPos = [-r, diffY];
}
if (bottomPos) {
if (markBottom) {
if (!bottom && bottom !== undefined) {
markBottom.remove();
} else {
markBottom.attr({
...styles,
x: bottomPos[0],
y: bottomPos[1],
});
}
} else if (bottom) {
group.addShape('circle', {
attrs: {
...styles,
x: bottomPos[0],
y: bottomPos[1],
},
className: 'link-point-bottom',
name: 'link-point-bottom',
isAnchorPoint: true,
});
}
}
},
},
'single-node',
);

View File

@ -0,0 +1,265 @@
/**
* @fileOverview Shape
* @author dxq613@gmail.com
*/
import { Group as GGroup } from '@antv/g-canvas';
import { IShape } from '@antv/g-canvas/lib/interfaces';
import { upperFirst } from '@antv/util';
import { ShapeOptions, ShapeDefine } from '../interface/shape';
import { IPoint, Item, ModelConfig, NodeConfig, EdgeConfig } from '../types';
import { createNodeFromXML } from './xml';
const cache: {
[key: string]: string;
} = {}; // ucfirst 开销过大,进行缓存
// 首字母大写
function ucfirst(str: string) {
if (!cache[str]) {
cache[str] = upperFirst(str);
}
return cache[str];
}
/**
*
* @type Shape.FactoryBase
*/
const ShapeFactoryBase = {
/**
* / shapeType 使
* @type {String}
*/
defaultShapeType: 'defaultType',
/**
* className
* @type {String}
*/
className: null,
/**
* Shape
* @param {String} type
* @return {Shape}
*/
getShape(type?: string): ShapeOptions {
const self = this as any;
const shape = self[type!] || self[self.defaultShapeType];
return shape;
},
/**
*
* @param {String} type
* @param {Object} cfg
* @param {G.Group} group
* @return {IShape}
*/
draw(type: string, cfg: ModelConfig, group: GGroup): IShape {
const shape = this.getShape(type);
const rst = shape.draw!(cfg, group);
if (shape.afterDraw) {
shape.afterDraw(cfg, group, rst);
}
return rst;
},
/**
*
* @param {String} type
* @param {Object} cfg
* @param {G6.Item} item
*/
baseUpdate(type: string, cfg: ModelConfig, item: Item) {
const shape = this.getShape(type);
if (shape.update) {
// 防止没定义 update 函数
shape.update(cfg, item);
}
if (shape.afterUpdate) {
shape.afterUpdate(cfg, item);
}
},
/**
*
* @param {String} type
* @param {String} name
* @param {String | Boolean} value
* @param {G6.Item} item
*/
setState(type: string, name: string, value: string | boolean, item: Item) {
const shape = this.getShape(type);
// 调用 shape/shapeBase.ts 中的 setState 方法
shape.setState!(name, value, item);
},
/**
*
* @param {String} type
* @return {Boolean} 使
*/
shouldUpdate(type: string): boolean {
const shape = this.getShape(type);
return !!shape.update;
},
getControlPoints(type: string, cfg: ModelConfig): IPoint[] | undefined {
const shape = this.getShape(type);
return shape.getControlPoints!(cfg);
},
/**
*
* @param {String} type
* @param {Object} cfg
* @return {Array|null} , null
*/
getAnchorPoints(type: string, cfg: ModelConfig): number[][] | undefined {
const shape = this.getShape(type);
return shape.getAnchorPoints!(cfg);
},
};
/**
*
*/
const ShapeFramework = {
// 默认样式及配置
options: {},
/**
*
*/
draw(cfg, group) {
return this.drawShape(cfg, group);
},
/**
*
*/
drawShape(/* cfg, group */) { },
/**
* 便
*/
afterDraw(/* cfg, group */) { },
// update(cfg, item) // 默认不定义
afterUpdate(/* cfg, item */) { },
/**
*
*/
setState(/* name, value, item */) { },
/**
*
* @param {Object} cfg
* @return {Array|null} , null
*/
getControlPoints(cfg: NodeConfig | EdgeConfig) {
return cfg.controlPoints;
},
/**
*
* @param {Object} cfg
* @return {Array|null} , null
*/
getAnchorPoints(cfg: NodeConfig | EdgeConfig) {
const { anchorPoints: defaultAnchorPoints } = this.options as any;
const anchorPoints = cfg.anchorPoints || defaultAnchorPoints;
return anchorPoints;
},
/* update draw
update(cfg, item) {
}
*/
};
export default class Shape {
public static Node: any;
public static Edge: any;
public static Combo: any;
public static registerFactory(factoryType: string, cfg: object): object {
const className = ucfirst(factoryType);
const factoryBase = ShapeFactoryBase;
const shapeFactory = { ...factoryBase, ...cfg } as any;
(Shape as any)[className] = shapeFactory;
shapeFactory.className = className;
return shapeFactory;
}
public static getFactory(factoryType: string) {
const className = ucfirst(factoryType);
return (Shape as any)[className];
}
public static registerNode(
shapeType: string,
nodeDefinition: ShapeOptions | ShapeDefine,
extendShapeType?: string,
) {
const shapeFactory = Shape.Node;
let shapeObj;
if (typeof nodeDefinition === 'string' || typeof nodeDefinition === 'function') {
const autoNodeDefinition = createNodeFromXML(nodeDefinition);
shapeObj = { ...shapeFactory.getShape('single-node'), ...autoNodeDefinition };
} else if (nodeDefinition.jsx) {
const { jsx } = nodeDefinition;
const autoNodeDefinition = createNodeFromXML(jsx);
shapeObj = {
...shapeFactory.getShape('single-node'),
...autoNodeDefinition,
...nodeDefinition,
};
} else {
const extendShape = extendShapeType ? shapeFactory.getShape(extendShapeType) : ShapeFramework;
shapeObj = { ...extendShape, ...nodeDefinition };
}
shapeObj.type = shapeType;
shapeObj.itemType = 'node';
shapeFactory[shapeType] = shapeObj;
return shapeObj;
}
public static registerEdge(
shapeType: string,
edgeDefinition: ShapeOptions,
extendShapeType?: string,
) {
const shapeFactory = Shape.Edge;
const extendShape = extendShapeType ? shapeFactory.getShape(extendShapeType) : ShapeFramework;
const shapeObj = { ...extendShape, ...edgeDefinition };
shapeObj.type = shapeType;
shapeObj.itemType = 'edge';
shapeFactory[shapeType] = shapeObj;
return shapeObj;
}
public static registerCombo(
shapeType: string,
comboDefinition: ShapeOptions,
extendShapeType?: string,
) {
const shapeFactory = Shape.Combo;
const extendShape = extendShapeType ? shapeFactory.getShape(extendShapeType) : ShapeFramework;
const shapeObj = { ...extendShape, ...comboDefinition };
shapeObj.type = shapeType;
shapeObj.itemType = 'combo';
shapeFactory[shapeType] = shapeObj;
return shapeObj;
}
}
// 注册 Node 的工厂方法
Shape.registerFactory('node', {
defaultShapeType: 'circle',
});
// 注册 Edge 的工厂方法
Shape.registerFactory('edge', {
defaultShapeType: 'line',
});
// 注册 Combo 的工厂方法
Shape.registerFactory('combo', {
defaultShapeType: 'circle',
});

View File

@ -0,0 +1,518 @@
/**
* @fileOverview
* @author dxq613@gmail.com
*/
import { Group as GGroup } from '@antv/g-canvas';
import { IShape, IElement } from '@antv/g-canvas/lib/interfaces';
import { ShapeOptions, ILabelConfig } from '../interface/shape';
import { IPoint, Item, LabelStyle, ShapeStyle, ModelConfig, EdgeConfig } from '../types';
import Global from '../global';
import { transform } from '@antv/matrix-util';
import { deepMix, each, mix, isBoolean, isPlainObject, clone, isString } from '@antv/util';
const CLS_SHAPE_SUFFIX = '-shape';
const CLS_LABEL_SUFFIX = '-label';
const ARROWS = ['startArrow', 'endArrow'];
const SHAPE_DEFAULT_ATTRS = {
lineWidth: 1,
stroke: undefined,
fill: undefined,
lineAppendWidth: 1,
opacity: undefined,
strokeOpacity: undefined,
fillOpacity: undefined,
x: 0,
y: 0,
r: 10,
width: 20,
height: 20,
shadowColor: undefined,
shadowBlur: 0,
shadowOffsetX: 0,
shadowOffsetY: 0
}
const PATH_SHAPE_DEFAULT_ATTRS = {
lineWidth: 1,
stroke: '#000',
lineDash: undefined,
startArrow: false,
endArrow: false,
opacity: undefined,
strokeOpacity: undefined,
fillOpacity: undefined,
shadowColor: undefined,
shadowBlur: 0,
shadowOffsetX: 0,
shadowOffsetY: 0
}
const SHAPES_DEFAULT_ATTRS = {
edge: PATH_SHAPE_DEFAULT_ATTRS,
node: SHAPE_DEFAULT_ATTRS,
combo: SHAPE_DEFAULT_ATTRS
}
export const CLS_LABEL_BG_SUFFIX = '-label-bg';
// 单个 shape 带有一个 label共用这段代码
export const shapeBase: ShapeOptions = {
// 默认样式及配置
options: {},
itemType: '', // node, edge, combo 等
/**
* circleellipsepolyline...
*/
type: '',
getCustomConfig(cfg: ModelConfig): ModelConfig {
return {};
},
getOptions(cfg: ModelConfig): ModelConfig {
return deepMix({
// 解决局部渲染导致的文字移动残影问题
labelCfg: {
style: {
fontFamily: typeof window !== 'undefined' ? window.getComputedStyle(document.body, null).getPropertyValue("font-family") || 'Arial, sans-serif' : 'Arial, sans-serif',
}
},
descriptionCfg: {
style: {
fontFamily: typeof window !== 'undefined' ? window.getComputedStyle(document.body, null).getPropertyValue("font-family") || 'Arial, sans-serif' : 'Arial, sans-serif',
}
}
}, this.options, this.getCustomConfig(cfg) || {}, cfg);
},
/**
* /
* @override
* @param {Object} cfg
* @param {G.Group} group
* @return {IShape}
*/
draw(cfg: ModelConfig, group: GGroup): IShape {
const shape: IShape = this.drawShape!(cfg, group);
shape.set('className', this.itemType + CLS_SHAPE_SUFFIX);
if (cfg.label) {
const label = this.drawLabel!(cfg, group);
label.set('className', this.itemType + CLS_LABEL_SUFFIX);
}
return shape;
},
/**
* 便
* @param cfg
* @param group
* @param keyShape
*/
afterDraw(cfg?: ModelConfig, group?: GGroup, keyShape?: IShape) { },
drawShape(cfg?: ModelConfig, group?: GGroup): IShape {
return null as any;
},
drawLabel(cfg: ModelConfig, group: GGroup): IShape {
const { labelCfg: defaultLabelCfg } = this.getOptions(cfg) as ModelConfig;
// image的情况下有可能为null
const labelCfg = (defaultLabelCfg || {}) as ILabelConfig;
const labelStyle = this.getLabelStyle!(cfg, labelCfg, group);
const rotate = labelStyle.rotate;
delete labelStyle.rotate;
const label = group.addShape('text', {
attrs: labelStyle,
draggable: true,
className: 'text-shape',
name: 'text-shape',
});
if (rotate) {
const labelBBox = label.getBBox();
let labelMatrix = label.getMatrix();
if (!labelMatrix) {
labelMatrix = [1, 0, 0, 0, 1, 0, 0, 0, 1];
}
if (labelStyle.rotateCenter) {
switch (labelStyle.rotateCenter) {
case 'center':
labelMatrix = transform(labelMatrix, [
['t', -labelBBox.width / 2, -labelBBox.height / 2],
['r', rotate],
['t', labelBBox.width / 2, labelBBox.height / 2],
]);
break;
case 'lefttop':
labelMatrix = transform(labelMatrix, [
['t', -labelStyle.x!, -labelStyle.y!],
['r', rotate],
['t', labelStyle.x, labelStyle.y],
]);
break;
case 'leftcenter':
labelMatrix = transform(labelMatrix, [
['t', -labelStyle.x!, -labelStyle.y! - labelBBox.height / 2],
['r', rotate],
['t', labelStyle.x, labelStyle.y! + labelBBox.height / 2],
]);
break;
default:
labelMatrix = transform(labelMatrix, [
['t', -labelBBox.width / 2, -labelBBox.height / 2],
['r', rotate],
['t', labelBBox.width / 2, labelBBox.height / 2],
]);
break;
}
} else {
labelMatrix = transform(labelMatrix, [
['t', -labelStyle.x!, -labelStyle.y! - labelBBox.height / 2],
['r', rotate],
['t', labelStyle.x, labelStyle.y! + labelBBox.height / 2],
]);
}
label.setMatrix(labelMatrix);
}
if (labelStyle.background) {
const rect = this.drawLabelBg(cfg, group, label);
const labelBgClassname = this.itemType + CLS_LABEL_BG_SUFFIX;
rect.set('classname', labelBgClassname);
label.toFront();
}
return label;
},
drawLabelBg(cfg: ModelConfig, group: GGroup, label: IElement) {
const { labelCfg: defaultLabelCfg } = this.options as ModelConfig;
const labelCfg = mix({}, defaultLabelCfg, cfg.labelCfg) as ILabelConfig;
const style = this.getLabelBgStyleByPosition(label, cfg, labelCfg, group);
const rect = group.addShape('rect', { name: 'text-bg-shape', attrs: style });
return rect;
},
getLabelStyleByPosition(cfg: ModelConfig, labelCfg?: ILabelConfig, group?: GGroup): LabelStyle {
return { text: cfg.label as string };
},
getLabelBgStyleByPosition(
label: IElement,
cfg: ModelConfig,
labelCfg?: ILabelConfig,
group?: GGroup,
): LabelStyle {
return {};
},
/**
*
* @param cfg
* @param labelCfg
* @param group label
*/
getLabelStyle(cfg: ModelConfig, labelCfg: ILabelConfig, group: GGroup): LabelStyle {
const calculateStyle = this.getLabelStyleByPosition!(cfg, labelCfg, group);
const attrName = `${this.itemType}Label`; // 取 nodeLabeledgeLabel 的配置项
const defaultStyle = (Global as any)[attrName] ? (Global as any)[attrName].style : null;
const labelStyle = { ...defaultStyle, ...calculateStyle, ...labelCfg.style };
return labelStyle;
},
/**
*
* @param cfg
*/
getShapeStyle(cfg: ModelConfig): ShapeStyle {
return cfg.style!;
},
/**
*
* @override
* @param {Object} cfg /
* @param {G6.Item} item /
*/
update(cfg: ModelConfig, item: Item) {
(this as any).updateShapeStyle(cfg, item);
(this as any).updateLabel(cfg, item);
},
updateShapeStyle(cfg: ModelConfig, item: Item) {
const group = item.getContainer();
const shape = item.getKeyShape();
const shapeStyle = mix({}, shape.attr(), cfg.style);
for (const key in shapeStyle) {
const style = shapeStyle[key];
if (isPlainObject(style)) {
// 更新图元素样式,支持更新子元素
const subShape = group.find((element) => element.get('name') === key);
if (subShape) {
subShape.attr(style);
}
} else {
shape.attr({
[key]: style,
});
}
}
},
updateLabel(cfg: ModelConfig, item: Item) {
const group = item.getContainer();
const { labelCfg: defaultLabelCfg } = this.getOptions({}) as ModelConfig;
const labelClassName = this.itemType + CLS_LABEL_SUFFIX;
const label = group.find((element) => element.get('className') === labelClassName);
const labelBgClassname = this.itemType + CLS_LABEL_BG_SUFFIX;
let labelBg = group.find((element) => element.get('classname') === labelBgClassname);
// 防止 cfg.label = "" 的情况
if (cfg.label || cfg.label === '') {
// 若传入的新配置中有 label用户没传入但原先有 labellabel 也会有值)
if (!label) {
// 若原先不存在 label则绘制一个新的 label
const newLabel = this.drawLabel!(cfg, group);
newLabel.set('className', labelClassName);
} else {
// 若原先存在 label则更新样式。与 getLabelStyle 不同在于这里需要融合当前 label 的样式
// 用于融合 style 以外的属性position, offset, ...
let currentLabelCfg = {} as any;
if (item.getModel) {
currentLabelCfg = item.getModel().labelCfg;
}
// 这里不能去掉
const labelCfg = deepMix({}, defaultLabelCfg, currentLabelCfg, cfg.labelCfg);
// 获取位置信息
const calculateStyle = (this as any).getLabelStyleByPosition(cfg, labelCfg, group);
// 取 nodeLabeledgeLabel 的配置项
const cfgStyle = cfg.labelCfg ? cfg.labelCfg.style : undefined;
const cfgBgStyle = labelCfg.style && labelCfg.style.background;
// 需要融合当前 label 的样式 label.attr()。不再需要全局/默认样式,因为已经应用在当前的 label 上
const labelStyle = { ...label.attr(), ...calculateStyle, ...cfgStyle };
const rotate = labelStyle.rotate;
delete labelStyle.rotate;
// 计算 label 的旋转矩阵
if (rotate) {
// if G 4.x define the rotateAtStart, use it directly instead of using the following codes
let rotateMatrix = [1, 0, 0, 0, 1, 0, 0, 0, 1];
rotateMatrix = transform(rotateMatrix, [
['t', -labelStyle.x, -labelStyle.y],
['r', rotate],
['t', labelStyle.x, labelStyle.y],
]);
label.resetMatrix();
label.attr({ ...labelStyle, matrix: rotateMatrix });
} else {
label.resetMatrix();
label.attr(labelStyle);
}
if (!labelBg) {
if (labelStyle.background) {
labelBg = this.drawLabelBg(cfg, group, label);
labelBg.set('classname', labelBgClassname);
label.toFront();
}
} else if (labelStyle.background) {
const calculateBgStyle = (this as any).getLabelBgStyleByPosition(
label,
cfg,
labelCfg,
group,
);
// const labelBgStyle = Object.assign({}, labelBg.attr(), calculateBgStyle, cfgBgStyle);
const labelBgStyle = { ...calculateBgStyle, ...cfgBgStyle };
labelBg.resetMatrix();
if (rotate) {
labelBg.rotateAtStart(rotate);
}
labelBg.attr(labelBgStyle);
} else {
group.removeChild(labelBg);
}
}
}
},
// update(cfg, item) // 默认不定义
afterUpdate(cfg?: ModelConfig, item?: Item) { },
/**
* draw
* selectedactive
* @override
* @param {String} name
* @param {String | Boolean} value
* @param {G6.Item} item
*/
setState(name: string, value: string | boolean, item: Item) {
const shape: IShape = item.get('keyShape');
if (!shape) {
return;
}
const type = item.getType();
const stateName = isBoolean(value) ? name : `${name}:${value}`;
const shapeStateStyle = this.getStateStyle(stateName, item);
const itemStateStyle = item.getStateStyle(stateName);
// 不允许设置一个不存在的状态
if (!itemStateStyle && !shapeStateStyle) {
return;
}
// 要设置或取消的状态的样式
// 当没有 state 状态时,默认使用 model.stateStyles 中的样式
const styles = mix({}, itemStateStyle || shapeStateStyle);
const group = item.getContainer();
// 从图元素现有的样式中删除本次要取消的 states 中存在的属性值。使用对象检索更快
const keptAttrs = { x: 1, y: 1, cx: 1, cy: 1 };
if (value) {
// style 为要设置的状态的样式
for (const key in styles) {
const style = styles[key];
if (isPlainObject(style) && !ARROWS.includes(key)) {
const subShape = group.find((element) => element.get('name') === key);
if (subShape) {
subShape.attr(style);
}
} else {
// 非纯对象,则认为是设置到 keyShape 上面的
shape.attr({
[key]: style,
});
}
}
} else {
// 所有生效的 state 的样式
const enableStatesStyle = clone(item.getCurrentStatesStyle());
const model = item.getModel();
// 原始样式
const tmp = item.getOriginStyle();
const originStyle = mix({}, model.style, clone(item.getOriginStyle()));
const keyShapeName = shape.get('name');
const keyShapeStyles = clone(shape.attr());
// 已有样式 - 要取消的状态的样式
const filtetDisableStatesStyle = {};
// styles 为要取消的状态的样式
for (const p in styles) {
const style = styles[p];
if (isPlainObject(style) && !ARROWS.includes(p)) {
const subShape = group.find((element) => element.get('name') === p);
if (subShape) {
const subShapeStyles = clone(subShape.attr());
each(style, (v, key) => {
if (p === keyShapeName && keyShapeStyles[key] && !keptAttrs[key]) {
delete keyShapeStyles[key];
const value = originStyle[p][key] || SHAPES_DEFAULT_ATTRS[type][key];
shape.attr(key, value);
} else if (subShapeStyles[key] || subShapeStyles[key] === 0) {
delete subShapeStyles[key];
const value = originStyle[p][key] || SHAPES_DEFAULT_ATTRS[type][key];
subShape.attr(key, value);
}
});
filtetDisableStatesStyle[p] = subShapeStyles;
}
} else {
if (keyShapeStyles[p] && !keptAttrs[p]) {
delete keyShapeStyles[p];
const value = originStyle[p] || (originStyle[keyShapeName] ? originStyle[keyShapeName][p] : undefined) || SHAPES_DEFAULT_ATTRS[type][p];
shape.attr(p, value);
}
}
}
// 从图元素现有的样式中删除本次要取消的 states 中存在的属性值后,
// 如果 keyShape 有 name 属性,则 filtetDisableStatesStyle 的格式为 { keyShapeName: {} }
// 否则为普通对象
if (!keyShapeName) {
mix(filtetDisableStatesStyle, keyShapeStyles);
} else {
filtetDisableStatesStyle[keyShapeName] = keyShapeStyles;
}
for (const key in enableStatesStyle) {
if (keptAttrs[key]) continue;
const enableStyle = enableStatesStyle[key];
if (!isPlainObject(enableStyle) || ARROWS.includes(key)) {
// 把样式属性merge到keyShape中
if (!keyShapeName) {
mix(originStyle, {
[key]: enableStyle,
});
} else {
mix(originStyle[keyShapeName], {
[key]: enableStyle,
});
delete originStyle[key];
}
delete enableStatesStyle[key];
}
}
const originstyles = {};
deepMix(originstyles, originStyle, filtetDisableStatesStyle, enableStatesStyle);
let keyShapeSetted = false;
for (const originKey in originstyles) {
const style = originstyles[originKey];
if (isPlainObject(style) && !ARROWS.includes(originKey)) {
const subShape = group.find((element) => element.get('name') === originKey);
if (subShape) {
subShape.attr(style);
if (originKey === keyShapeName) keyShapeSetted = true;
}
} else if (!keyShapeSetted) {
const value = style || SHAPES_DEFAULT_ATTRS[type][originKey];
// 当更新 combo 状态时,当不存在 keyShapeName 时候,则认为是设置到 keyShape 上面的
if (type === 'combo') {
if (!keyShapeName) {
shape.attr({
[originKey]: value,
});
}
} else {
shape.attr({
[originKey]: value,
});
}
}
}
}
},
/**
*
*
* @param {string} name
* @param {Item} item Node或Edge的实例
* @return {object}
*/
getStateStyle(name: string, item: Item): ShapeStyle {
const model = item.getModel();
const type = item.getType();
const { stateStyles, style = {} } = this.getOptions(model);
const modelStateStyle = model.stateStyles
? model.stateStyles[name]
: stateStyles && stateStyles[name];
if (type === 'combo') {
return clone(modelStateStyle);
}
return mix({}, style, modelStateStyle);
},
/**
*
* @param {Object} cfg
* @return {Array|null} , null
*/
getControlPoints(cfg: EdgeConfig): IPoint[] | undefined {
return cfg.controlPoints;
},
/**
*
* @param {Object} cfg
* @return {Array|null} , null
*/
getAnchorPoints(cfg: ModelConfig): number[][] | undefined {
const { anchorPoints } = this.getOptions(cfg) as ModelConfig;
return anchorPoints;
},
};

View File

@ -0,0 +1,571 @@
/**
* @fileOverview xml建立自定义Nodeupdate
* @author xuzhi.mxz@antfin.com
*/
import { get } from '@antv/util';
import { getTextSize } from '../util/graphic';
/**
* JSON
* @param text json
*/
function looseJSONParse(text) {
if (typeof text !== 'string') {
return text;
}
const safeParse = (str) => {
if (typeof str !== 'string') {
return str;
}
try {
return JSON.parse(str.trim());
} catch (e) {
return str.trim();
}
};
const firstAttempt = safeParse(text);
if (typeof firstAttempt !== 'string') {
return firstAttempt;
}
const tail = (arr) => arr[arr.length - 1];
const str = text.trim();
const objectStack = [];
const syntaxStack = [];
const isLastPair = (...syntaxes) => syntaxes.some((syntax) => tail(syntaxStack) === syntax);
const getValueStore = () => tail(objectStack);
let rst = null;
let i = 0;
let temp = '';
while (i < str.length) {
const nowChar = str[i];
const isInString = isLastPair('"', "'");
if (!isInString && !nowChar.trim()) {
i += 1;
continue;
}
const isLastTranslate = str[i - 1] === '\\';
const isInObject = isLastPair('}');
const isInArray = isLastPair(']');
const isWaitingValue = isLastPair(',');
const tempArr = getValueStore();
if (isInString) {
if (tail(syntaxStack) === nowChar && !isLastTranslate) {
syntaxStack.pop();
const value = safeParse(temp);
tempArr.push(value);
rst = value;
temp = '';
} else {
temp += nowChar;
}
} else if (isInArray && nowChar === ',') {
if (temp) {
tempArr.push(safeParse(temp));
temp = '';
}
} else if (isInObject && nowChar === ':') {
syntaxStack.push(',');
if (temp) {
tempArr.push(temp);
temp = '';
}
} else if (isWaitingValue && nowChar === ',') {
if (temp) {
tempArr.push(safeParse(temp));
temp = '';
}
syntaxStack.pop();
} else if (nowChar === '}' && (isInObject || isWaitingValue)) {
if (temp) {
tempArr.push(safeParse(temp));
temp = '';
}
if (isWaitingValue) {
syntaxStack.pop();
}
const obj = {};
for (let c = 1; c < tempArr.length; c += 2) {
obj[tempArr[c - 1]] = tempArr[c];
}
objectStack.pop();
if (objectStack.length) {
tail(objectStack).push(obj);
}
syntaxStack.pop();
rst = obj;
} else if (nowChar === ']' && isInArray) {
if (temp) {
tempArr.push(safeParse(temp));
temp = '';
}
objectStack.pop();
if (objectStack.length) {
tail(objectStack).push(tempArr);
}
syntaxStack.pop();
rst = tempArr;
} else if (nowChar === '{') {
objectStack.push([]);
syntaxStack.push('}');
} else if (nowChar === '[') {
objectStack.push([]);
syntaxStack.push(']');
} else if (nowChar === '"') {
syntaxStack.push('"');
} else if (nowChar === "'") {
syntaxStack.push("'");
} else {
temp += nowChar;
}
i += 1;
}
return rst || temp;
}
/**
*
*/
interface NodeInstructure {
type: string;
attrs: { [key: string]: any };
children: NodeInstructure[];
bbox: {
x: number;
y: number;
width: number;
height: number;
};
}
const keyConvert = (str) =>
str.split('-').reduce((a, b) => a + b.charAt(0).toUpperCase() + b.slice(1));
/**
* {{}}
* @param xml
*/
export const xmlDataRenderer = (xml: string) => (data) => {
const len = xml.length;
const arr = [];
let i = 0;
let tmp = '';
while (i < len) {
if (xml[i] === '{' && xml[i + 1] === '{') {
arr.push(tmp);
tmp = '';
i += 2;
} else if (xml[i] === '}' && xml[i + 1] === '}') {
if (arr.length) {
const last = arr.pop();
tmp = get(data, tmp, last.endsWith('=') ? `"{${tmp}}"` : tmp);
arr.push(last + tmp);
}
i += 2;
tmp = '';
} else {
tmp += xml[i];
i += 1;
}
}
arr.push(tmp);
return arr
.map((e, index) => (arr[index - 1] && arr[index - 1].endsWith('=') ? `"{${e}}"` : e))
.join('');
};
/**
* XMLJSON结构
* @param xml xml解析后的节点
*/
export function parseXML(xml: HTMLElement, cfg) {
let attrs = {} as { [key: string]: any };
const keys = (xml.getAttributeNames && xml.getAttributeNames()) || ([] as string[]);
const children =
xml.children && Array.from(xml.children).map((e) => parseXML(e as HTMLElement, cfg));
const rst = {} as { [key: string]: any } & NodeInstructure;
const tagName = xml.tagName ? xml.tagName.toLowerCase() : 'group';
if (tagName === 'text') {
attrs.text = xml.innerText;
}
rst.type = tagName;
if (tagName === 'img') {
rst.type = 'image';
}
Array.from(keys).forEach((k) => {
const key = keyConvert(k);
const val = xml.getAttribute(k);
try {
if (key === 'style' || key === 'attrs') {
const style = looseJSONParse(val);
attrs = {
...attrs,
...style,
};
} else {
rst[key] = looseJSONParse(val);
}
} catch (e) {
if (key === 'style') {
throw e;
}
rst[key] = val;
}
});
rst.attrs = attrs;
if (cfg && cfg.style && rst.name && typeof cfg.style[rst.name] === 'object') {
rst.attrs = {
...rst.attrs,
...cfg.style[rst.name],
};
}
if (cfg && cfg.style && rst.keyshape) {
rst.attrs = {
...rst.attrs,
...cfg.style,
};
}
if (children.length) {
rst.children = children;
}
return rst;
}
/**
* bounding box来得出该shape最终的bbox
*/
export function getBBox(
node: NodeInstructure,
offset: { x: number; y: number },
chilrenBBox: { width: number; height: number },
) {
const { attrs = {} } = node;
const bbox = {
x: offset.x || 0,
y: offset.y || 0,
width: chilrenBBox.width || 0,
height: chilrenBBox.height || 0,
};
let shapeHeight, shapeWidth;
switch (node.type) {
case 'maker':
case 'circle':
if (attrs.r) {
shapeWidth = 2 * attrs.r;
shapeHeight = 2 * attrs.r;
}
break;
case 'text':
if (attrs.text) {
shapeWidth = getTextSize(attrs.text, attrs.fontSize || 12)[0];
shapeHeight = 16;
bbox.y += shapeHeight;
bbox.height = shapeHeight;
bbox.width = shapeWidth;
node.attrs = {
fontSize: 12,
fill: '#000',
...attrs,
};
}
break;
default:
if (attrs.width) {
shapeWidth = attrs.width;
}
if (attrs.height) {
shapeHeight = attrs.height;
}
}
if (shapeHeight >= 0) {
bbox.height = shapeHeight;
}
if (shapeWidth >= 0) {
bbox.width = shapeWidth;
}
if (attrs.marginTop) {
bbox.y += attrs.marginTop;
}
if (attrs.marginLeft) {
bbox.x += attrs.marginLeft;
}
return bbox;
}
/**
* xml计算出的结构填上位置信息attrs
* @param target
* @param lastOffset
*/
export function generateTarget(target: NodeInstructure, lastOffset = { x: 0, y: 0 }) {
const defaultBbox = {
x: 0,
y: 0,
width: 0,
height: 0,
...lastOffset,
};
if (target.children?.length) {
const { attrs = {} } = target;
const { marginTop } = attrs;
const offset = { ...lastOffset };
if (marginTop) {
offset.y += marginTop;
}
for (let index = 0; index < target.children.length; index++) {
target.children[index].attrs.key = `${attrs.key || 'root'} -${index} `;
const node = generateTarget(target.children[index], offset);
if (node.bbox) {
const { bbox } = node;
if (node.attrs.next === 'inline') {
offset.x += node.bbox.width;
} else {
offset.y += node.bbox.height;
}
if (bbox.width + bbox.x > defaultBbox.width) {
defaultBbox.width = bbox.width + bbox.x;
}
if (bbox.height + bbox.y > defaultBbox.height) {
defaultBbox.height = bbox.height + bbox.y;
}
}
}
}
target.bbox = getBBox(target, lastOffset, defaultBbox);
target.attrs = {
...target.attrs,
...target.bbox,
};
return target;
}
/**
* node,
* 'add' delete change attrs 'restructure'
* @param nowTarget
* @param formerTarget
*/
export function compareTwoTarget(nowTarget: NodeInstructure, formerTarget: NodeInstructure) {
const { type } = nowTarget || {};
const { key } = formerTarget?.attrs || {};
if (key && nowTarget) {
nowTarget.attrs.key = key;
}
if (!nowTarget && formerTarget) {
return {
action: 'delete',
val: formerTarget,
type,
key,
};
}
if (nowTarget && !formerTarget) {
return {
action: 'add',
val: nowTarget,
type,
};
}
if (!nowTarget && !formerTarget) {
return {
action: 'same',
type,
};
}
const children = [];
if (nowTarget.children?.length > 0 || formerTarget.children?.length > 0) {
const length = Math.max(nowTarget.children?.length, formerTarget.children?.length);
const formerChilren = formerTarget.children || [];
const nowChilren = nowTarget.children || [];
for (let index = 0; index < length; index += 1) {
children.push(compareTwoTarget(nowChilren[index], formerChilren[index]));
}
}
const formerKeys = Object.keys(formerTarget.attrs);
const nowKeys = Object.keys(nowTarget.attrs);
if (formerTarget.type !== nowTarget.type) {
return {
action: 'restructure',
nowTarget,
formerTarget,
key,
children,
};
}
if (
formerKeys
.filter((e) => e !== 'children')
.some((e) => nowTarget.attrs[e] !== formerTarget.attrs[e] || !nowKeys.includes(e))
) {
return {
action: 'change',
val: nowTarget,
children,
type,
key,
};
}
return {
action: 'same',
children,
type,
key,
};
}
/**
* xml或者返回xml的函数构建自定义节点的结构
* @param gen
*/
export function createNodeFromXML(gen: string | ((node: any) => string)) {
const structures = {};
const compileXML = (cfg) => {
const rawStr = typeof gen === 'function' ? gen(cfg) : gen;
const target = xmlDataRenderer(rawStr)(cfg);
const xmlParser = document.createElement('div');
xmlParser.innerHTML = target;
const xml = xmlParser.children[0] as HTMLElement;
const result = generateTarget(parseXML(xml, cfg));
xmlParser.remove();
return result;
};
return {
draw(cfg, group) {
const resultTarget = compileXML(cfg);
let keyshape = group;
const renderTarget = (target) => {
const { attrs = {}, bbox, type, children, ...rest } = target;
if (target.type !== 'group') {
const shape = group.addShape(target.type, {
attrs,
origin: {
bbox,
type,
children,
},
...rest,
});
if (target.keyshape) {
keyshape = shape;
}
}
if (target.children) {
target.children.forEach((n) => renderTarget(n));
}
};
renderTarget(resultTarget);
structures[cfg.id] = [resultTarget];
return keyshape;
},
update(cfg, node) {
if (!structures[cfg.id]) {
structures[cfg.id] = [];
}
const container = node.getContainer();
const children = container.get('children');
const newTarget = compileXML(cfg);
const lastTarget = structures[cfg.id].pop();
const diffResult = compareTwoTarget(newTarget, lastTarget);
const addShape = (shape) => {
if (shape.type !== 'group') {
container.addShape(shape.type, { attrs: shape.attrs });
}
if (shape.children?.length) {
shape.children.map((e) => addShape(e));
}
};
const delShape = (shape) => {
const targetShape = children.find((e) => e.attrs.key === shape.attrs.key);
if (targetShape) {
container.removeChild(targetShape);
}
if (shape.children?.length) {
shape.children.map((e) => delShape(e));
}
};
const updateTarget = (target) => {
const { key } = target;
if (target.type !== 'group') {
const targetShape = children.find((e) => e.attrs.key === key);
switch (target.action) {
case 'change':
if (targetShape) {
const originAttr = target.val.keyshape ? node.getOriginStyle() : {};
targetShape.attr({ ...originAttr, ...target.val.attrs });
}
break;
case 'add':
addShape(target.val);
break;
case 'delete':
delShape(target.val);
break;
case 'restructure':
delShape(target.formerTarget);
addShape(target.nowTarget);
break;
default:
break;
}
}
if (target.children) {
target.children.forEach((n) => updateTarget(n));
}
};
updateTarget(diffResult);
structures[cfg.id].push(newTarget);
},
getAnchorPoints() {
return [
[0, 0.5],
[1, 0.5],
[0.5, 1],
[0.5, 0],
];
},
};
}

View File

@ -0,0 +1,804 @@
/* eslint @typescript-eslint/no-use-before-define: 0 */
import { IGroup, Event as GraphEvent } from '@antv/g-base';
import { BBox, AnimateCfg } from '@antv/g-base/lib/types';
import { Canvas, Shape } from '@antv/g-canvas';
import Node from '../item/node';
import { IGraph } from '../interface/graph';
import { IEdge, INode, ICombo } from '../interface/item';
import { ILabelConfig } from '../interface/shape';
type ShapeBase = Shape.Base;
// Node Edge Combo 实例
export type Item = INode | IEdge | ICombo;
export interface IG6GraphEvent extends GraphEvent {
item: Item | null;
canvasX: number;
canvasY: number;
clientX: number;
clientY: number;
x: number;
y: number;
wheelDelta: number;
detail: number;
key?: string;
target: IShapeBase & Canvas;
}
// Math types
export interface IPoint {
x: number;
y: number;
// 获取连接点时使用
anchorIndex?: number;
[key: string]: number | undefined;
}
export type IPointTuple = [number, number];
export type Matrix = number[];
export interface IBBox extends BBox {
centerX?: number;
centerY?: number;
[key: string]: number | undefined;
}
export type Padding = number | string | number[];
export interface ArrowConfig {
d?: number;
path?: string;
stroke?: string;
fill?: string;
}
// Shape types
export type ShapeStyle = Partial<{
x: number;
y: number;
r: number;
radius: number;
width: number;
height: number;
offset: number | number[];
stroke: string | null;
strokeOpacity: number;
fill: string | null;
fillOpacity: number;
lineWidth: number;
lineAppendWidth: number;
lineDash: number[];
path: string | object[];
points: object[];
matrix: number[];
opacity: number;
size: number | number[];
endArrow: boolean | ArrowConfig;
startArrow: boolean | ArrowConfig;
shadowColor: string;
shadowBlur: number;
shadowOffsetX: number;
shadowOffsetY: number;
cursor: string;
position: string;
fontSize: number;
keepVisualSize: boolean;
}>;
export interface IShapeBase extends ShapeBase {
isKeyShape: boolean;
}
export interface IRect extends IPoint {
width: number;
height: number;
}
export interface ICircle extends IPoint {
r: number;
}
export interface IEllipse extends IPoint {
rx: number;
ry: number;
}
export type SourceTarget = 'source' | 'target';
// 自环边配置
export type LoopConfig = Partial<{
dist: number;
position: string;
// 如果逆时针画,交换起点和终点
clockwise: boolean;
}>;
export interface LayoutConfig {
type?: string;
[key: string]: unknown;
}
export interface ModeOption {
type: string;
delegate?: boolean;
delegateStyle?: object;
updateEdge?: boolean;
trigger?: string;
enableDelegate?: boolean;
maxZoom?: number;
minZoom?: number;
enableOptimize?: boolean;
optimizeZoom?: number;
multiple?: boolean;
activeState?: string;
comboActiveState?: string;
selectedState?: string;
onlyChangeComboSize?: boolean;
includeEdges?: boolean;
direction?: 'x' | 'y';
scalableRange?: number;
offset?: number;
fixSelectedItems?: Partial<{
fixAll: boolean;
fixLineWidth: boolean;
fixLabel: boolean;
fixState: string;
}>;
key?: string | undefined;
edgeConfig?: EdgeConfig;
functionName?: string;
functionParams?: any[];
shouldUpdate?: (e: IG6GraphEvent) => boolean;
shouldBegin?: (e: IG6GraphEvent) => boolean;
shouldEnd?: (e: IG6GraphEvent) => boolean;
onChange?: (item?: Item, judge?: boolean) => unknown;
onSelect?: (selectedNodes?: Item[], selectedEdges?: Item[]) => unknown;
onDeselect?: (selectedNodes?: Item[], selectedEdges?: Item[]) => unknown;
formatText?: (data: { [key: string]: unknown }) => string;
}
export type ModeType = string | ModeOption;
export interface Modes {
default?: ModeType[];
[key: string]: ModeType[] | undefined;
}
// Graph 配置项中 state 的类型
export interface States {
[key: string]: INode[];
}
export interface StateStyles {
[key: string]:
| ShapeStyle
| {
[key: string]: ShapeStyle;
};
}
// model types (node edge group)
export type ModelStyle = Partial<{
[key: string]: unknown;
style: ShapeStyle;
stateStyles: StateStyles;
}>;
export interface GraphOptions {
/**
* DOM DOM id HTML
*/
container: string | HTMLElement;
/**
* 'px'
*/
width: number;
/**
* 'px'
*/
height: number;
/**
* renderer canvas or svg
*/
renderer?: string;
fitView?: boolean;
fitCenter?: boolean;
layout?: LayoutConfig;
/**
*
* , fitViewPadding: 20
* fitViewPadding: [20, 40, 50,20]
*
*/
fitViewPadding?: Padding;
/**
* false
* true
*/
groupByTypes?: boolean;
// 是否有向图
directed?: boolean;
groupStyle?: {
style?: {
[key: string]: ShapeStyle;
};
};
/**
* setAutoPaint()
* true
*/
autoPaint?: boolean;
/**
* G6中的Mode文档
*/
modes?: Modes;
/**
* type, size, color data
*/
defaultNode?: Partial<{
type: string;
size: number | number[];
color: string;
}> &
ModelStyle;
/**
* type, size, color data
*/
defaultEdge?: Partial<{
type: string;
size: number | number[];
color: string;
}> &
ModelStyle;
/**
* Combo
*/
defaultCombo?: Partial<{
type: string;
size: number | number[];
color: string;
}> &
ModelStyle;
nodeStateStyles?: StateStyles;
edgeStateStyles?: StateStyles;
// Combo 状态样式
comboStateStyles?: StateStyles;
/**
* graph plugin
*/
plugins?: any[];
/**
*
*/
animate?: boolean;
/**
* animate为true时有效
*/
animateCfg?: GraphAnimateConfig;
/**
*
* 0.2
*/
minZoom?: number;
/**
*
* 10
*/
maxZoom?: number;
groupType?: string;
/**
* Edge
*/
linkCenter?: boolean;
/**
* stack redo & undo
*/
enabledStack?: boolean;
/**
* redo & undo , enabledStack true
*/
maxStep?: number;
/**
* tooltip dom便
*/
tooltips?: [];
}
export type LabelStyle = Partial<{
rotate: number;
textAlign: string;
angle: number;
x: number;
y: number;
text: string;
stroke: string | null;
opacity: number;
fontSize: number;
fontStyle: string;
fill: string | null;
rotateCenter: string;
lineWidth?: number;
shadowColor?: string;
shadowBlur?: number;
shadowOffsetX?: number;
shadowOffsetY?: number;
position: string;
textBaseline: string;
offset: number;
background?: {
fill?: string;
stroke?: string;
lineWidth?: number;
radius?: number[] | number;
padding?: number[] | number;
};
}>;
export type Easeing =
| 'easeLinear'
| 'easePolyIn'
| 'easePolyOut'
| 'easePolyInOut'
| 'easeQuad'
| 'easeQuadIn'
| 'easeQuadOut'
| 'easeQuadInOut'
| string;
export interface ModelConfig extends ModelStyle {
// 节点或边的类型
type?: string;
label?: string | LabelStyle;
labelCfg?: ILabelConfig;
x?: number;
y?: number;
size?: number | number[];
color?: string;
anchorPoints?: number[][];
startPoint?: {
x: number;
y: number;
};
endPoint?: {
x: number;
y: number;
};
visible?: boolean;
}
export interface TreeGraphData {
id: string;
label?: string;
x?: number;
y?: number;
children?: TreeGraphData[];
data?: ModelConfig;
side?: 'left' | 'right';
depth?: number;
collapsed?: boolean;
style?:
| ShapeStyle
| {
[key: string]: ShapeStyle;
};
stateStyles?: StateStyles;
[key: string]: unknown;
}
export interface NodeConfig extends ModelConfig {
id: string;
groupId?: string;
comboId?: string;
children?: TreeGraphData[];
description?: string;
descriptionCfg?: {
style?: object;
[key: string]: unknown;
};
img?: string;
innerR?: number;
direction?: string;
preRect?: {
show?: boolean;
[key: string]: unknown;
};
logoIcon?: {
show?: boolean;
[key: string]: unknown;
};
stateIcon?: {
show?: boolean;
[key: string]: unknown;
};
linkPoints?: {
top?: boolean;
right?: boolean;
bottom?: boolean;
left?: boolean;
size?: number;
lineWidth?: number;
fill?: string;
stroke?: string;
r?: number;
[key: string]: unknown;
};
icon?: {
show?: boolean;
// icon的地址字符串类型
img?: string;
width?: number;
height?: number;
offset?: number;
};
clipCfg?: {
show?: boolean;
type?: string;
// circle
r?: number;
// ellipse
rx?: number;
ry?: number;
// rect
width?: number;
height?: number;
// polygon
points?: number[][];
// path
path?: Array<Array<string | number>>;
// 坐标
x?: number;
y?: number;
// clip 的属性样式
// style?: ShapeStyle
};
}
export interface ComboTree {
id: string;
label?: string | LabelStyle;
children?: ComboTree[];
depth?: number;
parentId?: string;
removed?: boolean;
itemType?: 'node' | 'combo';
[key: string]: unknown;
}
export interface ComboConfig extends ModelConfig {
id: string;
parentId?: string;
children?: ComboTree[];
depth?: number;
padding?: number | number[];
collapseIcon?: Partial<{
show: boolean;
collapseSymbol: any;
expandSymbol: any;
r: number;
lineWidth: number;
stroke: string;
offsetX: number;
offsetY: number;
}>;
}
export interface EdgeConfig extends ModelConfig {
id?: string;
source?: string;
target?: string;
sourceNode?: Node;
targetNode?: Node;
startPoint?: IPoint;
endPoint?: IPoint;
controlPoints?: IPoint[];
curveOffset?: number | number[];
// loop edge config
loopCfg?: LoopConfig;
labelCfg?: ILabelConfig;
}
export type EdgeData = EdgeConfig & {
sourceNode: Node;
targetNode: Node;
startPoint: IPoint;
endPoint: IPoint;
};
export interface NodeMap {
[key: string]: INode;
}
export interface NodeConfigMap {
[key: string]: NodeConfig;
}
export interface GraphData {
nodes?: NodeConfig[];
edges?: EdgeConfig[];
combos?: ComboConfig[];
}
export interface GraphAnimateConfig extends AnimateCfg {
/**
*
*/
onFrame?: (item: Item, ratio: number, data?: GraphData, originAttrs?: ShapeStyle) => unknown;
}
export interface GroupNodeIds {
[key: string]: string[];
}
// Behavior type file
export enum G6Event {
// common events
CLICK = 'click',
DBLCLICK = 'dblclick',
MOUSEDOWN = 'mousedown',
MOUDEUP = 'mouseup',
CONTEXTMENU = 'contextmenu',
MOUSEENTER = 'mouseenter',
MOUSEOUT = 'mouseout',
MOUSEOVER = 'mouseover',
MOUSEMOVE = 'mousemove',
MOUSELEAVE = 'mouseleave',
DRAGSTART = 'dragstart',
DRAGEND = 'dragend',
DRAG = 'drag',
DRAGENTER = 'dragenter',
DRAGLEAVE = 'dragleave',
DRAGOVER = 'dragover',
DRAGOUT = 'dragout',
DDROP = 'drop',
KEYUP = 'keyup',
KEYDOWN = 'keydown',
WHEEL = 'wheel',
FOCUS = 'focus',
BLUR = 'blur',
// touch events
TOUCHSTART = 'touchstart',
TOUCHMOVE = 'touchmove',
TOUCHEND = 'touchend',
// node events
NODE_CONTEXTMENU = 'node:contextmenu',
NODE_CLICK = 'node:click',
NODE_DBLCLICK = 'node:dblclick',
NODE_MOUSEDOWN = 'node:mousedown',
NODE_MOUSEUP = 'node:mouseup',
NODE_MOUSEENTER = 'node:mouseenter',
NODE_MOUSELEAVE = 'node:mouseleave',
NODE_MOUSEMOVE = 'node:mousemove',
NODE_MOUSEOUT = 'node:mouseout',
NODE_MOUSEOVER = 'node:mouseover',
NODE_DROP = 'node:drop',
NODE_DRAGOVER = 'node:dragover',
NODE_DRAGENTER = 'node:dragenter',
NODE_DRAGLEAVE = 'node:dragleave',
NODE_DRAGSTART = 'node:dragstart',
NODE_DRAG = 'node:drag',
NODE_DRAGEND = 'node:dragend',
// combo, extends from nodes
COMBO_CONTEXTMENU = 'combo:contextmenu',
COMBO_CLICK = 'combo:click',
COMBO_DBLCLICK = 'combo:dblclick',
COMBO_MOUSEDOWN = 'combo:mousedown',
COMBO_MOUSEUP = 'combo:mouseup',
COMBO_MOUSEENTER = 'combo:mouseenter',
COMBO_MOUSELEAVE = 'combo:mouseleave',
COMBO_MOUSEMOVE = 'combo:mousemove',
COMBO_MOUSEOUT = 'combo:mouseout',
COMBO_MOUSEOVER = 'combo:mouseover',
COMBO_DROP = 'combo:drop',
COMBO_DRAGOVER = 'combo:dragover',
COMBO_DRAGENTER = 'combo:dragenter',
COMBO_DRAGLEAVE = 'combo:dragleave',
COMBO_DRAGSTART = 'combo:dragstart',
COMBO_DRAG = 'combo:drag',
COMBO_DRAGEND = 'combo:dragend',
// edge events
EDGE_CONTEXTMENU = 'edge:contextmenu',
EDGE_CLICK = 'edge:click',
EDGE_DBLCLICK = 'edge:dblclick',
EDGE_MOUSEDOWN = 'edge:mousedown',
EDGE_MOUSEUP = 'edge:mouseup',
EDGE_MOUSEENTER = 'edge:mouseenter',
EDGE_MOUSELEAVE = 'edge:mouseleave',
EDGE_MOUSEMOVE = 'edge:mousemove',
EDGE_MOUSEOUT = 'edge:mouseout',
EDGE_MOUSEOVER = 'edge:mouseover',
EDGE_DROP = 'edge:drop',
EDGE_DRAGOVER = 'edge:dragover',
EDGE_DRAGENTER = 'edge:dragenter',
EDGE_DRAGLEAVE = 'edge:dragleave',
// canvas events
CANVAS_CONTEXTMENU = 'canvas:contextmenu',
CANVAS_CLICK = 'canvas:click',
CANVAS_DBLCLICK = 'canvas:dblclick',
CANVAS_MOUSEDOWN = 'canvas:mousedown',
CANVAS_MOUSEUP = 'canvas:mouseup',
CANVAS_MOUSEENTER = 'canvas:mouseenter',
CANVAS_MOUSELEAVE = 'canvas:mouseleave',
CANVAS_MOUSEMOVE = 'canvas:mousemove',
CANVAS_MOUSEOUT = 'canvas:mouseout',
CANVAS_MOUSEOVER = 'canvas:mouseover',
CANVAS_DROP = 'canvas:drop',
CANVAS_DRAGENTER = 'canvas:dragenter',
CANVAS_DRAGLEAVE = 'canvas:dragleave',
CANVAS_DRAGSTART = 'canvas:dragstart',
CANVAS_DRAG = 'canvas:drag',
CANVAS_DRAGEND = 'canvas:dragend',
// timing events
BEFORERENDER = 'beforerender',
AFTERRENDER = 'afterrender',
BEFOREADDITEM = 'beforeadditem',
AFTERADDITEM = 'afteradditem',
BEFOREREMOVEITEM = 'beforeremoveitem',
AFTERREMOVEITEM = 'afterremoveitem',
BEFOREUPDATEITEM = 'beforeupdateitem',
AFTERUPDATEITEM = 'afterupdateitem',
BEFOREITEMVISIBILITYCHANGE = 'beforeitemvisibilitychange',
AFTERITEMVISIBILITYCHANGE = 'afteritemvisibilitychange',
BEFOREITEMSTATECHANGE = 'beforeitemstatechange',
AFTERITEMSTATECHANGE = 'afteritemstatechange',
BEFOREITEMREFRESH = 'beforeitemrefresh',
AFTERITEMREFRESH = 'afteritemrefresh',
BEFOREITEMSTATESCLEAR = 'beforeitemstatesclear',
AFTERITEMSTATESCLEAR = 'afteritemstatesclear',
BEFOREMODECHANGE = 'beforemodechange',
AFTERMODECHANGE = 'aftermodechange',
BEFORELAYOUT = 'beforelayout',
AFTERLAYOUT = 'afterlayout',
BEFORECREATEEDGE = 'beforecreateedge',
AFTERCREATEEDGE = 'aftercreateedge',
BEFOREGRAPHREFRESHPOSITION = 'beforegraphrefreshposition',
AFTERGRAPHREFRESHPOSITION = 'aftergraphrefreshposition',
BEFOREGRAPHREFRESH = 'beforegraphrefresh',
AFTERGRAPHREFRESH = 'aftergraphrefresh',
BEFOREANIMATE = 'beforeanimate',
AFTERANIMATE = 'afteranimate',
BEFOREPAINT = 'beforepaint',
AFTERPAINT = 'afterpaint',
GRAPHSTATECHANGE = 'graphstatechange',
AFTERACTIVATERELATIONS = 'afteractivaterelations',
NODESELECTCHANGE = 'nodeselectchange',
TOOLTIPCHANGE = 'tooltipchange',
WHEELZOOM = 'wheelzoom',
VIEWPORTCHANGE = 'viewportchange',
DRAGNODEEND = 'dragnodeend',
STACKCHANGE = 'stackchange'
}
export type DefaultBehaviorType = IG6GraphEvent | string | number | object;
export interface BehaviorOption {
getEvents(): {
[key in G6Event]?: string;
};
getDefaultCfg?(): object;
shouldBegin?(e?: IG6GraphEvent): boolean;
shouldUpdate?(e?: IG6GraphEvent): boolean;
shouldEnd?(e?: IG6GraphEvent): boolean;
bind?(e: IGraph): void;
unbind?(e: IGraph): void;
[key: string]: unknown;
}
export type IEvent = Record<G6Event, string>;
// eslint-disable-next-line @typescript-eslint/naming-convention
export type ITEM_TYPE = 'node' | 'edge' | 'combo' | 'vedge';
export type NodeIdxMap = {
[key: string]: number;
};
// 触发 viewportchange 事件的参数
export interface ViewPortEventParam {
action: string;
matrix: Matrix;
}
export interface Indexable<T> {
[key: string]: T;
}
// 图算法回调方法接口定义
export interface IAlgorithmCallbacks {
enter?: (param: { current: INode; previous: INode }) => void;
leave?: (param: { current: INode; previous?: INode }) => void;
allowTraversal?: (param: { previous?: INode; current?: INode; next: INode }) => boolean;
}
// 栈中数据格式
export interface StackData {
action: string;
data: GraphData;
}
export interface BubblesetCfg {
morphBuffer?: number; // DEFAULT_NODE_R0; the amount of space to move the virtual edge when wrapping around obstacles
pixelGroupSize?: number; // the resolution of the algorithm in square pixels, 4 by default
maxMarchingIterations?: number; // number of times to refine the boundary, 100 by default
maxRoutingIterations?: number; // number of times to run the algorithm to refine the path finding in difficult areas
nodeR0?: number; // the distance from nodes which energy is 1 (full influence)
nodeR1?: number; // the distance from nodes at which energy is 0 (no influence)
edgeR0?: number; // the distance from edges at which energy is 1 (full influence)
edgeR1?: number; // the distance from edges at which energy is 0 (no influence)
nodeInfluenceFactor?: number; // node influence factor
negativeNodeInfluenceFactor?: number; // negativeNode influence factor
edgeInfluenceFactor?: number; // edge influence factor
memberInfluenceFactor?: number; // member influence factor
nonMemberInfluenceFactor?: number; // nonMember influence factor
}
export type TimeBarType = 'trend' | 'simple' | 'tick'
export type WaterMarkerConfig = Partial<{
// 水印 canvas 容器的宽高
width: number;
height: number;
compatible: boolean;
text: {
x?: number;
y?: number;
lineHeight?: number;
rotate?: number;
fontSize?: number;
fontFamily?: string;
fill?: string;
baseline?: string;
},
image: {
x?: number;
y?: number;
// 图片的宽高
width?: number;
height: number;
rotate?: number;
}
}>
export interface HullCfg {
id: string;
members?: Item[] | string[]; // 节点实例或节点 Id 数组
nonMembers?: Item[] | string[];
group?: IGroup;
type?: string; // 'round-convex'(圆角凸包) /'smooth-convex'(平滑凸包) / 'bubble'(平滑凹包),
padding?: number; // 轮廓边缘和内部成员间距
style?: {
fill?: string;
stroke?: string;
opacity?: number;
};
bubbleCfg?: BubblesetCfg; // 用于更精细控制bubble的效果点和边轮廓的松弛程度轮廓粒度一般不需要配置
}

View File

@ -0,0 +1,207 @@
import { isString, isNumber, isNil, isArray } from '@antv/util';
import { G6GraphEvent } from '../interface/behavior';
import { IG6GraphEvent, Padding, Matrix, Item } from '../types';
/**
* turn padding into [top, right, bottom, right]
* @param {Number|Array} padding input padding
* @return {array} output
*/
export const formatPadding = (padding: Padding): number[] => {
let top = 0;
let left = 0;
let right = 0;
let bottom = 0;
if (isNumber(padding)) {
top = left = right = bottom = padding;
} else if (isString(padding)) {
const intPadding = parseInt(padding, 10);
top = left = right = bottom = intPadding;
} else if (isArray(padding)) {
top = padding[0];
right = !isNil(padding[1]) ? padding[1] : padding[0];
bottom = !isNil(padding[2]) ? padding[2] : padding[0];
left = !isNil(padding[3]) ? padding[3] : right;
}
return [top, right, bottom, left];
};
/**
* clone event
* @param e
*/
export const cloneEvent = (e: IG6GraphEvent): IG6GraphEvent => {
const event = new G6GraphEvent(e.type, e);
event.clientX = e.clientX;
event.clientY = e.clientY;
event.x = e.x;
event.y = e.y;
event.target = e.target;
event.currentTarget = e.currentTarget;
event.bubbles = true;
(event.item as Item | null) = e.item;
return event;
};
/**
* viewport
* @param matrix Viewport Matrix
*/
export const isViewportChanged = (matrix: Matrix) => {
// matrix 为 null 则说明没有变化
if (!matrix) {
return false;
}
const MATRIX_LEN = 9;
const ORIGIN_MATRIX = [1, 0, 0, 0, 1, 0, 0, 0, 1];
for (let i = 0; i < MATRIX_LEN; i++) {
if (matrix[i] !== ORIGIN_MATRIX[i]) {
return true;
}
}
return false;
};
export const isNaN = (input: any) => Number.isNaN(Number(input));
/**
* Item BBox
* @param items Item node combo
*/
export const calculationItemsBBox = (items: Item[]) => {
let minx = Infinity;
let maxx = -Infinity;
let miny = Infinity;
let maxy = -Infinity;
// 获取已节点的所有最大最小x y值
for (let i = 0; i < items.length; i++) {
const element = items[i];
const bbox = element.getBBox();
const { minX, minY, maxX, maxY } = bbox;
if (minX < minx) {
minx = minX;
}
if (minY < miny) {
miny = minY;
}
if (maxX > maxx) {
maxx = maxX;
}
if (maxY > maxy) {
maxy = maxY;
}
}
const x = Math.floor(minx);
const y = Math.floor(miny);
const width = Math.ceil(maxx) - Math.floor(minx);
const height = Math.ceil(maxy) - Math.floor(miny);
return {
x,
y,
width,
height,
minX: minx,
minY: miny,
maxX: maxx,
maxY: maxy,
};
};
/**
* edges 使 quadratic curveOffset 使
* @param edges
* @param offsetDiff offset
*/
export const processParallelEdges = (
edges,
offsetDiff = 15,
multiEdgeType: string = 'quadratic',
singleEdgeType: string = 'line',
) => {
const len = edges.length;
const cod = offsetDiff * 2;
const loopPosition = [
'top',
'top-right',
'right',
'bottom-right',
'bottom',
'bottom-left',
'left',
'top-left',
];
const edgeMap = {};
const tags = [];
const reverses = {};
for (let i = 0; i < len; i++) {
const edge = edges[i];
const { source, target } = edge;
const sourceTarget = `${source}-${target}`;
if (tags[i]) continue;
if (!edgeMap[sourceTarget]) {
edgeMap[sourceTarget] = [];
}
tags[i] = true;
edgeMap[sourceTarget].push(edge);
for (let j = 0; j < len; j++) {
if (i === j) continue;
const sedge = edges[j];
const src = sedge.source;
const dst = sedge.target;
// 两个节点之间共同的边
// 第一条的source = 第二条的target
// 第一条的target = 第二条的source
if (!tags[j]) {
if (source === dst && target === src) {
edgeMap[sourceTarget].push(sedge);
tags[j] = true;
reverses[`${src}|${dst}|${edgeMap[sourceTarget].length - 1}`] = true;
} else if (source === src && target === dst) {
edgeMap[sourceTarget].push(sedge);
tags[j] = true;
}
}
}
}
for (const key in edgeMap) {
const arcEdges = edgeMap[key];
const { length } = arcEdges;
for (let k = 0; k < length; k++) {
const current = arcEdges[k];
if (current.source === current.target) {
current.type = 'loop';
// 超过8条自环边则需要重新处理
current.loopCfg = {
position: loopPosition[k % 8],
dist: Math.floor(k / 8) * 20 + 50,
};
continue;
}
if (length === 1) {
current.type = singleEdgeType;
continue;
}
current.type = multiEdgeType;
const sign =
(k % 2 === 0 ? 1 : -1) * (reverses[`${current.source}|${current.target}|${k}`] ? -1 : 1);
if (length % 2 === 1) {
current.curveOffset = sign * Math.ceil(k / 2) * cod;
} else {
current.curveOffset = sign * (Math.floor(k / 2) * cod + offsetDiff);
}
}
}
return edges;
};

View File

@ -0,0 +1,742 @@
import { Group } from '@antv/g-canvas';
import Path from '@antv/g-canvas/lib/shape/path';
import { vec2 } from '@antv/matrix-util';
import Global from '../global';
import {
EdgeData,
IBBox,
IPoint,
IShapeBase,
LabelStyle,
TreeGraphData,
NodeConfig,
ComboTree,
ComboConfig,
} from '../types';
import { applyMatrix } from './math';
import letterAspectRatio from './letterAspectRatio';
import { isString, clone } from '@antv/util';
import { BBox } from '@antv/g-math/lib/types';
import { IGraph } from '../interface/graph';
const { PI, sin, cos } = Math;
// 一共支持8个方向的自环每个环占的角度是45度在计算时再二分为22.5度
const SELF_LINK_SIN: number = sin(PI / 8);
const SELF_LINK_COS: number = cos(PI / 8);
export const getBBox = (element: IShapeBase, group: Group): IBBox => {
const bbox = element.getBBox();
let leftTop: IPoint = {
x: bbox.minX,
y: bbox.minY,
};
let rightBottom: IPoint = {
x: bbox.maxX,
y: bbox.maxY,
};
// 根据父元素变换矩阵
if (group) {
let matrix = group.getMatrix();
if (!matrix) {
matrix = [1, 0, 0, 0, 1, 0, 0, 0, 1];
}
leftTop = applyMatrix(leftTop, matrix);
rightBottom = applyMatrix(rightBottom, matrix);
}
const { x: lx, y: ly } = leftTop;
const { x: rx, y: ry } = rightBottom;
return {
x: lx,
y: ly,
minX: lx,
minY: ly,
maxX: rx,
maxY: ry,
width: rx - lx,
height: ry - ly,
};
};
/**
* get loop edge config
* @param cfg edge config
*/
export const getLoopCfgs = (cfg: EdgeData): EdgeData => {
const item = cfg.sourceNode || cfg.targetNode;
const container: Group = item.get('group');
let containerMatrix = container.getMatrix();
if (!containerMatrix) containerMatrix = [1, 0, 0, 0, 1, 0, 0, 0, 1];
const keyShape: IShapeBase = item.getKeyShape();
const bbox: IBBox = keyShape.getBBox();
const loopCfg = cfg.loopCfg || {};
// 距离keyShape边的最高距离
const dist: number = loopCfg.dist || Math.max(bbox.width, bbox.height) * 2;
// 自环边与keyShape的相对位置关系
const position: string = loopCfg.position || Global.defaultLoopPosition;
// 中心取group上真实位置
const center = [containerMatrix[6], containerMatrix[7]];
let startPoint = [cfg.startPoint.x, cfg.startPoint.y];
let endPoint = [cfg.endPoint.x, cfg.endPoint.y];
let rstart = bbox.height / 2;
let rend = bbox.height / 2;
let sinDeltaStart = rstart * SELF_LINK_SIN;
let cosDeltaStart = rstart * SELF_LINK_COS;
let sinDeltaEnd = rend * SELF_LINK_SIN;
let cosDeltaEnd = rend * SELF_LINK_COS;
// 如果定义了锚点的,直接用锚点坐标,否则,根据自环的 cfg 计算
if (startPoint[0] === endPoint[0] && startPoint[1] === endPoint[1]) {
switch (position) {
case 'top':
startPoint = [center[0] - sinDeltaStart, center[1] - cosDeltaStart];
endPoint = [center[0] + sinDeltaEnd, center[1] - cosDeltaEnd];
break;
case 'top-right':
rstart = bbox.height / 2;
rend = bbox.width / 2;
sinDeltaStart = rstart * SELF_LINK_SIN;
cosDeltaStart = rstart * SELF_LINK_COS;
sinDeltaEnd = rend * SELF_LINK_SIN;
cosDeltaEnd = rend * SELF_LINK_COS;
startPoint = [center[0] + sinDeltaStart, center[1] - cosDeltaStart];
endPoint = [center[0] + cosDeltaEnd, center[1] - sinDeltaEnd];
break;
case 'right':
rstart = bbox.width / 2;
rend = bbox.width / 2;
sinDeltaStart = rstart * SELF_LINK_SIN;
cosDeltaStart = rstart * SELF_LINK_COS;
sinDeltaEnd = rend * SELF_LINK_SIN;
cosDeltaEnd = rend * SELF_LINK_COS;
startPoint = [center[0] + cosDeltaStart, center[1] - sinDeltaStart];
endPoint = [center[0] + cosDeltaEnd, center[1] + sinDeltaEnd];
break;
case 'bottom-right':
rstart = bbox.width / 2;
rend = bbox.height / 2;
sinDeltaStart = rstart * SELF_LINK_SIN;
cosDeltaStart = rstart * SELF_LINK_COS;
sinDeltaEnd = rend * SELF_LINK_SIN;
cosDeltaEnd = rend * SELF_LINK_COS;
startPoint = [center[0] + cosDeltaStart, center[1] + sinDeltaStart];
endPoint = [center[0] + sinDeltaEnd, center[1] + cosDeltaEnd];
break;
case 'bottom':
rstart = bbox.height / 2;
rend = bbox.height / 2;
sinDeltaStart = rstart * SELF_LINK_SIN;
cosDeltaStart = rstart * SELF_LINK_COS;
sinDeltaEnd = rend * SELF_LINK_SIN;
cosDeltaEnd = rend * SELF_LINK_COS;
startPoint = [center[0] + sinDeltaStart, center[1] + cosDeltaStart];
endPoint = [center[0] - sinDeltaEnd, center[1] + cosDeltaEnd];
break;
case 'bottom-left':
rstart = bbox.height / 2;
rend = bbox.width / 2;
sinDeltaStart = rstart * SELF_LINK_SIN;
cosDeltaStart = rstart * SELF_LINK_COS;
sinDeltaEnd = rend * SELF_LINK_SIN;
cosDeltaEnd = rend * SELF_LINK_COS;
startPoint = [center[0] - sinDeltaStart, center[1] + cosDeltaStart];
endPoint = [center[0] - cosDeltaEnd, center[1] + sinDeltaEnd];
break;
case 'left':
rstart = bbox.width / 2;
rend = bbox.width / 2;
sinDeltaStart = rstart * SELF_LINK_SIN;
cosDeltaStart = rstart * SELF_LINK_COS;
sinDeltaEnd = rend * SELF_LINK_SIN;
cosDeltaEnd = rend * SELF_LINK_COS;
startPoint = [center[0] - cosDeltaStart, center[1] + sinDeltaStart];
endPoint = [center[0] - cosDeltaEnd, center[1] - sinDeltaEnd];
break;
case 'top-left':
rstart = bbox.width / 2;
rend = bbox.height / 2;
sinDeltaStart = rstart * SELF_LINK_SIN;
cosDeltaStart = rstart * SELF_LINK_COS;
sinDeltaEnd = rend * SELF_LINK_SIN;
cosDeltaEnd = rend * SELF_LINK_COS;
startPoint = [center[0] - cosDeltaStart, center[1] - sinDeltaStart];
endPoint = [center[0] - sinDeltaEnd, center[1] - cosDeltaEnd];
break;
default:
rstart = bbox.width / 2;
rend = bbox.width / 2;
sinDeltaStart = rstart * SELF_LINK_SIN;
cosDeltaStart = rstart * SELF_LINK_COS;
sinDeltaEnd = rend * SELF_LINK_SIN;
cosDeltaEnd = rend * SELF_LINK_COS;
startPoint = [center[0] - sinDeltaStart, center[1] - cosDeltaStart];
endPoint = [center[0] + sinDeltaEnd, center[1] - cosDeltaEnd];
}
// 如果逆时针画,交换起点和终点
if (loopCfg.clockwise === false) {
const swap = [startPoint[0], startPoint[1]];
startPoint = [endPoint[0], endPoint[1]];
endPoint = [swap[0], swap[1]];
}
}
const startVec = [startPoint[0] - center[0], startPoint[1] - center[1]];
let scaleRateStart = (rstart + dist) / rstart;
let scaleRateEnd = (rend + dist) / rend;
if (loopCfg.clockwise === false) {
scaleRateStart = (rend + dist) / rend;
scaleRateEnd = (rstart + dist) / rstart;
}
const startExtendVec = vec2.scale([], startVec, scaleRateStart);
const controlPoint1 = [center[0] + startExtendVec[0], center[1] + startExtendVec[1]];
const endVec = [endPoint[0] - center[0], endPoint[1] - center[1]];
const endExtendVec = vec2.scale([], endVec, scaleRateEnd);
const controlPoint2 = [center[0] + endExtendVec[0], center[1] + endExtendVec[1]];
cfg.startPoint = { x: startPoint[0], y: startPoint[1] };
cfg.endPoint = { x: endPoint[0], y: endPoint[1] };
cfg.controlPoints = [
{ x: controlPoint1[0], y: controlPoint1[1] },
{ x: controlPoint2[0], y: controlPoint2[1] },
];
return cfg;
};
/**
* label 线 label
* @param {object} pathShape G path Edge keyShape
* @param {number} percent 0 - 1 线
* @param {number} refX x label
* @param {number} refY y label
* @param {boolean} rotate 线
* @return {object} x, y,
*/
export const getLabelPosition = (
pathShape: Path,
percent: number,
refX: number,
refY: number,
rotate: boolean,
): LabelStyle => {
const TAN_OFFSET = 0.0001;
let vector: number[][] = [];
const point: IPoint = pathShape.getPoint(percent);
if (point === null) {
return {
x: 0,
y: 0,
angle: 0,
};
}
// 头尾最可能,放在最前面,使用 g path 上封装的方法
if (percent < TAN_OFFSET) {
vector = pathShape.getStartTangent().reverse();
} else if (percent > 1 - TAN_OFFSET) {
vector = pathShape.getEndTangent();
} else {
// 否则取指定位置的点,与少量偏移的点,做微分向量
const offsetPoint: IPoint = pathShape.getPoint(percent + TAN_OFFSET);
vector.push([point.x, point.y]);
vector.push([offsetPoint.x, offsetPoint.y]);
}
let rad: number = Math.atan2(vector[1][1] - vector[0][1], vector[1][0] - vector[0][0]);
if (rad < 0) {
rad += PI * 2;
}
if (refX) {
point.x += cos(rad) * refX;
point.y += sin(rad) * refX;
}
if (refY) {
// 默认方向是 x 轴正方向,法线是 求出角度 - 90°
let normal = rad - PI / 2;
// 若法线角度在 y 轴负方向,切到正方向,保证 refY 相对于 y 轴正方向
if (rad > (1 / 2) * PI && rad < ((3 * 1) / 2) * PI) {
normal -= PI;
}
point.x += cos(normal) * refY;
point.y += sin(normal) * refY;
}
const result = {
x: point.x,
y: point.y,
angle: rad,
};
if (rotate) {
if (rad > (1 / 2) * PI && rad < ((3 * 1) / 2) * PI) {
rad -= PI;
}
return {
rotate: rad,
...result,
};
}
return result;
};
/**
* depth first traverse, from root to leaves, children in inverse order
* if the fn returns false, terminate the traverse
*/
const traverse = <T extends { children?: T[] }>(data: T, fn: (param: T) => boolean) => {
if (fn(data) === false) {
return false;
}
if (data && data.children) {
for (let i = data.children.length - 1; i >= 0; i--) {
if (!traverse(data.children[i], fn)) return false;
}
}
return true;
};
/**
* depth first traverse, from leaves to root, children in inverse order
* if the fn returns false, terminate the traverse
*/
const traverseUp = <T extends { children?: T[] }>(data: T, fn: (param: T) => boolean) => {
if (data && data.children) {
for (let i = data.children.length - 1; i >= 0; i--) {
if (!traverseUp(data.children[i], fn)) return;
}
}
if (fn(data) === false) {
return false;
}
return true;
};
/**
* depth first traverse, from root to leaves, children in inverse order
* if the fn returns false, terminate the traverse
*/
export const traverseTree = <T extends { children?: T[] }>(data: T, fn: (param: T) => boolean) => {
if (typeof fn !== 'function') {
return;
}
traverse(data, fn);
};
/**
* depth first traverse, from leaves to root, children in inverse order
* if the fn returns false, terminate the traverse
*/
export const traverseTreeUp = <T extends { children?: T[] }>(
data: T,
fn: (param: T) => boolean,
) => {
if (typeof fn !== 'function') {
return;
}
traverseUp(data, fn);
};
export type TreeGraphDataWithPosition = TreeGraphData & {
x: number;
y: number;
children?: TreeGraphDataWithPosition[];
};
/**
*
* @param data Tree graph data
* @param layout
*/
export const radialLayout = (
data: TreeGraphDataWithPosition,
layout?: string,
): TreeGraphDataWithPosition => {
// 布局方式有 H / V / LR / RL / TB / BT
const VERTICAL_LAYOUTS: string[] = ['V', 'TB', 'BT'];
const min: IPoint = {
x: Infinity,
y: Infinity,
};
const max: IPoint = {
x: -Infinity,
y: -Infinity,
};
// 默认布局是垂直布局TB此时x对应rady对应r
let rScale: 'x' | 'y' = 'x';
let radScale: 'x' | 'y' = 'y';
if (layout && VERTICAL_LAYOUTS.indexOf(layout) >= 0) {
// 若是水平布局y对应radx对应r
radScale = 'x';
rScale = 'y';
}
let count = 0;
traverseTree(data, (node: TreeGraphDataWithPosition) => {
count++;
if (node.x > max.x) {
max.x = node.x;
}
if (node.x < min.x) {
min.x = node.x;
}
if (node.y > max.y) {
max.y = node.y;
}
if (node.y < min.y) {
min.y = node.y;
}
return true;
});
const avgRad = (PI * 2) / count;
const radDiff = max[radScale] - min[radScale];
if (radDiff === 0) {
return data;
}
traverseTree(data, (node) => {
const radial = ((node[radScale] - min[radScale]) / radDiff) * (PI * 2 - avgRad) + avgRad;
const r = Math.abs(rScale === 'x' ? node.x - data.x : node.y - data.y);
node.x = r * Math.cos(radial);
node.y = r * Math.sin(radial);
return true;
});
return data;
};
/**
*
* @param letter the letter
* @param fontSize
* @return the letter's width
*/
export const getLetterWidth = (letter, fontSize) => {
return fontSize * (letterAspectRatio[letter] || 1);
};
/**
*
* @param text the text
* @param fontSize
* @return the text's size
*/
export const getTextSize = (text, fontSize) => {
let width = 0;
const pattern = new RegExp('[\u{4E00}-\u{9FA5}]+');
text.split('').forEach((letter) => {
if (pattern.test(letter)) {
// 中文字符
width += fontSize;
} else {
width += getLetterWidth(letter, fontSize);
}
});
return [width, fontSize];
};
/**
* construct the trees from combos data
* @param array the combos array
* @param nodes the nodes array
* @return the tree
*/
export const plainCombosToTrees = (array: ComboConfig[], nodes?: NodeConfig[]) => {
const result: ComboTree[] = [];
const addedMap = {};
const modelMap = {};
array.forEach((d) => {
modelMap[d.id] = d;
});
array.forEach((d, i) => {
const cd = clone(d);
cd.itemType = 'combo';
cd.children = undefined;
if (cd.parentId === cd.id) {
console.warn(`The parentId for combo ${cd.id} can not be the same as the combo's id`);
delete cd.parentId;
} else if (cd.parentId && !modelMap[cd.parentId]) {
console.warn(`The parent combo for combo ${cd.id} does not exist!`);
delete cd.parentId;
}
let mappedObj = addedMap[cd.id];
if (mappedObj) {
cd.children = mappedObj.children;
addedMap[cd.id] = cd;
mappedObj = cd;
if (!mappedObj.parentId) {
result.push(mappedObj);
return;
}
const mappedParent = addedMap[mappedObj.parentId];
if (mappedParent) {
if (mappedParent.children) mappedParent.children.push(cd);
else mappedParent.children = [cd];
} else {
const parent = {
id: mappedObj.parentId,
children: [mappedObj],
};
addedMap[mappedObj.parentId] = parent;
addedMap[cd.id] = cd;
}
return;
}
if (isString(d.parentId)) {
const parent = addedMap[d.parentId];
if (parent) {
if (parent.children) parent.children.push(cd);
else parent.children = [cd];
addedMap[cd.id] = cd;
} else {
const pa = {
id: d.parentId,
children: [cd],
};
addedMap[pa.id] = pa;
addedMap[cd.id] = cd;
}
} else {
result.push(cd);
addedMap[cd.id] = cd;
}
});
// proccess the nodes
const nodeMap = {};
(nodes || []).forEach((node) => {
nodeMap[node.id] = node;
const combo = addedMap[node.comboId as string];
if (combo) {
const cnode: NodeConfig = {
id: node.id,
comboId: node.comboId as string,
};
if (combo.children) combo.children.push(cnode);
else combo.children = [cnode];
cnode.itemType = 'node';
addedMap[node.id] = cnode;
}
});
// assign the depth for each element
let maxDepth = 0;
result.forEach((tree: ComboTree) => {
tree.depth = maxDepth + 10;
traverse<ComboTree>(tree, (child) => {
let parent;
const itemType = addedMap[child.id].itemType;
if (itemType === 'node') {
parent = addedMap[child.comboId as string];
} else {
parent = addedMap[child.parentId];
}
if (parent) {
if (itemType === 'node') child.depth = maxDepth + 1;
else child.depth = maxDepth + 10;
} else {
child.depth = maxDepth + 10;
}
if (maxDepth < child.depth) maxDepth = child.depth;
const oriNodeModel = nodeMap[child.id];
if (oriNodeModel) {
oriNodeModel.depth = child.depth;
}
return true;
});
});
return result;
};
export const reconstructTree = (
trees: ComboTree[],
subtreeId?: string,
newParentId?: string | undefined,
): ComboTree[] => {
let brothers = trees;
let subtree;
const comboChildsMap = {
root: {
children: trees,
},
};
let foundSubTree = false;
let oldParentId = 'root';
(trees || []).forEach((tree) => {
if (foundSubTree) return;
if (tree.id === subtreeId) {
subtree = tree;
if (tree.itemType === 'combo') {
subtree.parentId = newParentId;
} else {
subtree.comboId = newParentId;
}
foundSubTree = true;
return;
}
traverseTree<ComboTree>(tree, (child: any) => {
comboChildsMap[child.id] = {
children: child.children,
};
// store the old parent id to delete the subtree from the old parent's children in next recursion
brothers = comboChildsMap[child.parentId || child.comboId || 'root'].children;
if (child && (child.removed || subtreeId === child.id) && brothers) {
oldParentId = child.parentId || child.comboId || 'root';
subtree = child;
// re-assign the parentId or comboId for the moved subtree
if (child.itemType === 'combo') {
subtree.parentId = newParentId;
} else {
subtree.comboId = newParentId;
}
foundSubTree = true;
return false;
}
return true;
});
});
brothers = comboChildsMap[oldParentId].children;
const index = brothers ? brothers.indexOf(subtree) : -1;
if (index > -1) brothers.splice(index, 1);
// 如果遍历完整棵树还没有找到,说明之前就不在树中
if (!foundSubTree) {
subtree = {
id: subtreeId,
itemType: 'node',
comboId: newParentId,
};
comboChildsMap[subtreeId] = {
children: undefined,
};
}
// append to new parent
if (subtreeId) {
let found = false;
// newParentId is undefined means the subtree will have no parent
if (newParentId) {
let newParentDepth = 0;
(trees || []).forEach((tree) => {
if (found) return; // terminate
traverseTree<ComboTree>(tree, (child: any) => {
// append subtree to the new parent ans assign the depth to the subtree
if (newParentId === child.id) {
found = true;
if (child.children) child.children.push(subtree);
else child.children = [subtree];
newParentDepth = child.depth;
if (subtree.itemType === 'node') subtree.depth = newParentDepth + 2;
else subtree.depth = newParentDepth + 1;
return false; // terminate
}
return true;
});
});
} else if ((!newParentId || !found) && subtree.itemType !== 'node') {
// if the newParentId is undefined or it is not found in the tree, add the subTree to the root
trees.push(subtree);
}
// update the depth of the subtree and its children from the subtree
let currentDepth = subtree.depth;
traverseTree<ComboTree>(subtree, (child: any) => {
if (child.itemType === 'node') currentDepth += 2;
else currentDepth += 1;
child.depth = currentDepth;
return true;
});
}
return trees;
};
export const getComboBBox = (children: ComboTree[], graph: IGraph): BBox => {
const comboBBox = {
minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity,
x: undefined,
y: undefined,
width: undefined,
height: undefined,
centerX: undefined,
centerY: undefined,
};
if (!children || children.length === 0) {
return comboBBox;
}
children.forEach((child) => {
const childItem = graph.findById(child.id);
if (!childItem || !childItem.isVisible()) return; // ignore hidden children
childItem.set('bboxCanvasCache', undefined);
const childBBox = childItem.getCanvasBBox();
if (childBBox.x && comboBBox.minX > childBBox.minX) comboBBox.minX = childBBox.minX;
if (childBBox.y && comboBBox.minY > childBBox.minY) comboBBox.minY = childBBox.minY;
if (childBBox.x && comboBBox.maxX < childBBox.maxX) comboBBox.maxX = childBBox.maxX;
if (childBBox.y && comboBBox.maxY < childBBox.maxY) comboBBox.maxY = childBBox.maxY;
});
comboBBox.x = (comboBBox.minX + comboBBox.maxX) / 2;
comboBBox.y = (comboBBox.minY + comboBBox.maxY) / 2;
comboBBox.width = comboBBox.maxX - comboBBox.minX;
comboBBox.height = comboBBox.maxY - comboBBox.minY;
comboBBox.centerX = (comboBBox.minX + comboBBox.maxX) / 2;
comboBBox.centerY = (comboBBox.minY + comboBBox.maxY) / 2;
Object.keys(comboBBox).forEach((key) => {
if (comboBBox[key] === Infinity || comboBBox[key] === -Infinity) {
comboBBox[key] = undefined;
}
});
return comboBBox;
};
export const getChartRegion = (
params: {
group: Group,
width: number,
height: number,
x: number,
y: number
}) => {
const {
group,
height,
width,
x,
y
} = params;
const canvas = group.get('canvas');
const canvasWidth = canvas.get('width');
const canvasHeight = canvas.get('height');
const region = {
start: {
x: 0,
y: 0
},
end: {
x: 0,
y: 0
}
}
region.start.x = x / canvasWidth;
region.start.y = y / canvasHeight;
region.end.x = (x + width) / canvasWidth;
region.end.y = (y + height) / canvasHeight;
return region;
}

View File

@ -0,0 +1,19 @@
import * as MathUtil from './math';
import * as GraphicUtil from './graphic';
import * as PathUtil from './path';
import * as BaseUtil from './base';
import { mat3, transform } from '@antv/matrix-util';
import { mix, isNumber, deepMix, isArray, uniqueId } from '@antv/util';
const Base = {
mat3,
mix,
deepMix,
transform,
isArray,
isNumber,
uniqueId
};
const Util = { ...Base, ...BaseUtil, ...GraphicUtil, ...PathUtil, ...MathUtil };
export default Util;

View File

@ -0,0 +1,95 @@
export default {
' ': 0.3329986572265625,
a: 0.5589996337890625,
A: 0.6569992065429687,
b: 0.58599853515625,
B: 0.6769989013671875,
c: 0.5469985961914062,
C: 0.7279998779296875,
d: 0.58599853515625,
D: 0.705999755859375,
e: 0.554998779296875,
E: 0.63699951171875,
f: 0.37299957275390627,
F: 0.5769989013671875,
g: 0.5909988403320312,
G: 0.7479995727539063,
h: 0.555999755859375,
H: 0.7199996948242188,
i: 0.255999755859375,
I: 0.23699951171875,
j: 0.26699981689453123,
J: 0.5169998168945312,
k: 0.5289993286132812,
K: 0.6899993896484375,
l: 0.23499908447265624,
L: 0.5879989624023437,
m: 0.854998779296875,
M: 0.8819992065429687,
n: 0.5589996337890625,
N: 0.7189987182617188,
o: 0.58599853515625,
O: 0.7669998168945312,
p: 0.58599853515625,
P: 0.6419998168945312,
q: 0.58599853515625,
Q: 0.7669998168945312,
r: 0.3649993896484375,
R: 0.6759994506835938,
s: 0.504998779296875,
S: 0.6319992065429687,
t: 0.354998779296875,
T: 0.6189987182617187,
u: 0.5599990844726562,
U: 0.7139999389648437,
v: 0.48199920654296874,
V: 0.6389999389648438,
w: 0.754998779296875,
W: 0.929998779296875,
x: 0.5089996337890625,
X: 0.63699951171875,
y: 0.4959991455078125,
Y: 0.66199951171875,
z: 0.48699951171875,
Z: 0.6239990234375,
'0': 0.6,
'1': 0.40099945068359377,
'2': 0.6,
'3': 0.6,
'4': 0.6,
'5': 0.6,
'6': 0.6,
'7': 0.5469985961914062,
'8': 0.6,
'9': 0.6,
'[': 0.3329986572265625,
']': 0.3329986572265625,
',': 0.26399993896484375,
'.': 0.26399993896484375,
';': 0.26399993896484375,
':': 0.26399993896484375,
'{': 0.3329986572265625,
'}': 0.3329986572265625,
'\\': 0.5,
'|': 0.19499969482421875,
'=': 0.604998779296875,
'+': 0.604998779296875,
'-': 0.604998779296875,
_: 0.5,
'`': 0.3329986572265625,
' ~': 0.8329986572265625,
'!': 0.3329986572265625,
'@': 0.8579986572265625,
'#': 0.6,
$: 0.6,
'%': 0.9699996948242188,
'^': 0.517999267578125,
'&': 0.7259994506835937,
'*': 0.505999755859375,
'(': 0.3329986572265625,
')': 0.3329986572265625,
'<': 0.604998779296875,
'>': 0.604998779296875,
'/': 0.5,
'?': 0.53699951171875,
};

View File

@ -0,0 +1,783 @@
import { Point } from '@antv/g-base/lib/types';
import { IGroup } from '@antv/g-canvas/lib/interfaces';
import { mat3, transform, vec3 } from '@antv/matrix-util';
import { isArray, each } from '@antv/util';
import {
GraphData,
ICircle,
IEllipse,
IRect,
Matrix,
EdgeConfig,
NodeIdxMap,
IBBox,
Item,
IPoint,
} from '../types';
/**
*
* @param {string} attributeName
* @param {number} min
* @param {number} max
* @return {boolean} bool
*/
export const compare = (attributeName: string) => {
return (m, n) => {
return m[attributeName] - n[attributeName];
}
};
/**
*
* @param {number} value
* @param {number} min
* @param {number} max
* @return {boolean} bool
*/
const isBetween = (value: number, min: number, max: number) => value >= min && value <= max;
/**
* 线
* @param {Point} p0 线
* @param {Point} p1 线
* @param {Point} p2 线
* @param {Point} p3 线
* @return {Point}
*/
export const getLineIntersect = (p0: Point, p1: Point, p2: Point, p3: Point): Point | null => {
const tolerance = 0.001;
const E: Point = {
x: p2.x - p0.x,
y: p2.y - p0.y,
};
const D0: Point = {
x: p1.x - p0.x,
y: p1.y - p0.y,
};
const D1: Point = {
x: p3.x - p2.x,
y: p3.y - p2.y,
};
const kross: number = D0.x * D1.y - D0.y * D1.x;
const sqrKross: number = kross * kross;
const sqrLen0: number = D0.x * D0.x + D0.y * D0.y;
const sqrLen1: number = D1.x * D1.x + D1.y * D1.y;
let point: Point | null = null;
if (sqrKross > tolerance * sqrLen0 * sqrLen1) {
const s = (E.x * D1.y - E.y * D1.x) / kross;
const t = (E.x * D0.y - E.y * D0.x) / kross;
if (isBetween(s, 0, 1) && isBetween(t, 0, 1)) {
point = {
x: p0.x + s * D0.x,
y: p0.y + s * D0.y,
};
}
}
return point;
};
/**
* point and rectangular intersection point
* @param {IRect} rect rect
* @param {Point} point point
* @return {PointPoint} rst;
*/
export const getRectIntersectByPoint = (rect: IRect, point: Point): Point | null => {
const { x, y, width, height } = rect;
const cx = x + width / 2;
const cy = y + height / 2;
const points: Point[] = [];
const center: Point = {
x: cx,
y: cy,
};
points.push({
x,
y,
});
points.push({
x: x + width,
y,
});
points.push({
x: x + width,
y: y + height,
});
points.push({
x,
y: y + height,
});
points.push({
x,
y,
});
let rst: Point | null = null;
for (let i = 1; i < points.length; i++) {
rst = getLineIntersect(points[i - 1], points[i], center, point);
if (rst) {
break;
}
}
return rst;
};
/**
* get point and circle inIntersect
* @param {ICircle} circle x,y,r
* @param {Point} point x,y
* @return {Point} applied point
*/
export const getCircleIntersectByPoint = (circle: ICircle, point: Point): Point | null => {
const { x: cx, y: cy, r } = circle;
const { x, y } = point;
const dx = x - cx;
const dy = y - cy;
const d = Math.sqrt(dx * dx + dy * dy);
if (d < r) {
return null;
}
const signX = Math.sign(dx);
const signY = Math.sign(dy);
const angle = Math.atan(dy / dx);
return {
x: cx + Math.abs(r * Math.cos(angle)) * signX,
y: cy + Math.abs(r * Math.sin(angle)) * signY,
};
};
/**
* get point and ellipse inIntersect
* @param {Object} ellipse x,y,rx,ry
* @param {Object} point x,y
* @return {object} applied point
*/
export const getEllipseIntersectByPoint = (ellipse: IEllipse, point: Point): Point => {
const a = ellipse.rx;
const b = ellipse.ry;
const cx = ellipse.x;
const cy = ellipse.y;
const dx = point.x - cx;
const dy = point.y - cy;
// 直接通过 x,y 求夹角,求出来的范围是 -PI, PI
let angle = Math.atan2(dy / b, dx / a);
if (angle < 0) {
angle += 2 * Math.PI; // 转换到 02PI
}
return {
x: cx + a * Math.cos(angle),
y: cy + b * Math.sin(angle),
};
};
/**
* coordinate matrix transformation
* @param {number} point coordinate
* @param {Matrix} matrix matrix
* @param {number} tag could be 0 or 1
* @return {Point} transformed point
*/
export const applyMatrix = (point: Point, matrix: Matrix, tag: 0 | 1 = 1): Point => {
const vector = [point.x, point.y, tag];
if (!matrix || isNaN(matrix[0])) {
matrix = [1, 0, 0, 0, 1, 0, 0, 0, 1];
}
vec3.transformMat3(vector, vector, matrix);
return {
x: vector[0],
y: vector[1],
};
};
/**
* coordinate matrix invert transformation
* @param {number} point coordinate
* @param {number} matrix matrix
* @param {number} tag could be 0 or 1
* @return {object} transformed point
*/
export const invertMatrix = (point: Point, matrix: Matrix, tag: 0 | 1 = 1): Point => {
if (!matrix || isNaN(matrix[0])) {
matrix = [1, 0, 0, 0, 1, 0, 0, 0, 1];
}
let inversedMatrix = mat3.invert([], matrix);
if (!inversedMatrix) {
inversedMatrix = [1, 0, 0, 0, 1, 0, 0, 0, 1];
}
const vector = [point.x, point.y, tag];
vec3.transformMat3(vector, vector, inversedMatrix);
return {
x: vector[0],
y: vector[1],
};
};
/**
*
* @param p1 First coordinate
* @param p2 second coordinate
* @param p3 three coordinate
*/
export const getCircleCenterByPoints = (p1: Point, p2: Point, p3: Point): Point => {
const a = p1.x - p2.x;
const b = p1.y - p2.y;
const c = p1.x - p3.x;
const d = p1.y - p3.y;
const e = (p1.x * p1.x - p2.x * p2.x - p2.y * p2.y + p1.y * p1.y) / 2;
const f = (p1.x * p1.x - p3.x * p3.x - p3.y * p3.y + p1.y * p1.y) / 2;
const denominator = b * c - a * d;
return {
x: -(d * e - b * f) / denominator,
y: -(a * f - c * e) / denominator,
};
};
/**
* get distance by two points
* @param p1 first point
* @param p2 second point
*/
export const distance = (p1: Point, p2: Point): number => {
const vx = p1.x - p2.x;
const vy = p1.y - p2.y;
return Math.sqrt(vx * vx + vy * vy);
};
/**
* scale matrix
* @param matrix [ [], [], [] ]
* @param ratio
*/
export const scaleMatrix = (matrix: Matrix[], ratio: number) => {
const result: Matrix[] = [];
matrix.forEach((row) => {
const newRow: number[] = [];
row.forEach((v) => {
newRow.push(v * ratio);
});
result.push(newRow);
});
return result;
};
/**
* Floyd Warshall algorithm for shortest path distances matrix
* @param {array} adjMatrix adjacency matrix
* @return {array} distances shortest path distances matrix
*/
export const floydWarshall = (adjMatrix: Matrix[]): Matrix[] => {
// initialize
const dist: Matrix[] = [];
const size = adjMatrix.length;
for (let i = 0; i < size; i += 1) {
dist[i] = [];
for (let j = 0; j < size; j += 1) {
if (i === j) {
dist[i][j] = 0;
} else if (adjMatrix[i][j] === 0 || !adjMatrix[i][j]) {
dist[i][j] = Infinity;
} else {
dist[i][j] = adjMatrix[i][j];
}
}
}
// floyd
for (let k = 0; k < size; k += 1) {
for (let i = 0; i < size; i += 1) {
for (let j = 0; j < size; j += 1) {
if (dist[i][j] > dist[i][k] + dist[k][j]) {
dist[i][j] = dist[i][k] + dist[k][j];
}
}
}
}
return dist;
};
/**
* get adjacency matrix
* @param data graph data
* @param directed whether it's a directed graph
*/
export const getAdjMatrix = (data: GraphData, directed: boolean): Matrix[] => {
const { nodes, edges } = data;
const matrix: Matrix[] = [];
// map node with index in data.nodes
const nodeMap: {
[key: string]: number;
} = {};
if (!nodes) {
throw new Error('invalid nodes data!');
}
if (nodes) {
nodes.forEach((node, i) => {
nodeMap[node.id] = i;
const row: number[] = [];
matrix.push(row);
});
}
if (edges) {
edges.forEach((e) => {
const { source, target } = e;
const sIndex = nodeMap[source as string];
const tIndex = nodeMap[target as string];
matrix[sIndex][tIndex] = 1;
if (!directed) {
matrix[tIndex][sIndex] = 1;
}
});
}
return matrix;
};
/**
* group
* @param group Group
* @param vec
*/
export const translate = (group: IGroup, vec: Point) => {
group.translate(vec.x, vec.y);
};
/**
*
* @param group Group
* @param point
*/
export const move = (group: IGroup, point: Point) => {
let matrix: Matrix = group.getMatrix();
if (!matrix) {
matrix = [1, 0, 0, 0, 1, 0, 0, 0, 1];
}
const bbox = group.getCanvasBBox();
const vx = point.x - bbox.minX;
const vy = point.y - bbox.minY;
const movedMatrix = transform(matrix, [['t', vx, vy]]);
group.setMatrix(movedMatrix);
};
/**
* group
* @param group Group
* @param point x y
*/
export const scale = (group: IGroup, ratio: number | number[]) => {
let matrix: Matrix = group.getMatrix();
if (!matrix) {
matrix = [1, 0, 0, 0, 1, 0, 0, 0, 1];
}
let scaleXY = ratio;
if (!isArray(ratio)) {
scaleXY = [ratio, ratio];
}
if (isArray(ratio) && ratio.length === 1) {
scaleXY = [ratio[0], ratio[0]];
}
matrix = transform(matrix, [['s', (scaleXY as number[])[0], (scaleXY as number[])[1]]]);
group.setMatrix(matrix);
};
/**
*
* @param group Group
* @param ratio
*/
export const rotate = (group: IGroup, angle: number) => {
let matrix: Matrix = group.getMatrix();
if (!matrix) {
matrix = [1, 0, 0, 0, 1, 0, 0, 0, 1];
}
matrix = transform(matrix, [['r', angle]]);
group.setMatrix(matrix);
};
export const getDegree = (n: number, nodeIdxMap: NodeIdxMap, edges: EdgeConfig[]): number[] => {
const degrees: number[] = [];
for (let i = 0; i < n; i++) {
degrees[i] = 0;
}
edges.forEach((e) => {
if (e.source) {
degrees[nodeIdxMap[e.source]] += 1;
}
if (e.target) {
degrees[nodeIdxMap[e.target]] += 1;
}
});
return degrees;
};
// 判断点Q是否在p1和p2的线段上
function onSegment(p1, p2, q) {
if (
(q[0] - p1[0]) * (p2[1] - p1[1]) === (p2[0] - p1[0]) * (q[1] - p1[1]) &&
Math.min(p1[0], p2[0]) <= q[0] &&
q[0] <= Math.max(p1[0], p2[0]) &&
Math.min(p1[1], p2[1]) <= q[1] &&
q[1] <= Math.max(p1[1], p2[1])
) {
return true;
}
return false;
}
/**
* P在多边形内-线. Borrow from https://github.com/antvis/util/blob/master/packages/path-util/src/point-in-polygon.ts
* @param points
* @param x
* @param y
*/
export const isPointInPolygon = (points: number[][], x: number, y: number) => {
let isHit = false;
const n = points.length;
// 判断两个double在eps精度下的大小关系
const tolerance = 1e-6;
function dcmp(xValue) {
if (Math.abs(xValue) < tolerance) {
return 0;
}
return xValue < 0 ? -1 : 1;
}
if (n <= 2) {
// svg 中点小于 3 个时,不显示,也无法被拾取
return false;
}
for (let i = 0; i < n; i++) {
const p1 = points[i];
const p2 = points[(i + 1) % n];
if (onSegment(p1, p2, [x, y])) {
// 点在多边形一条边上
return true;
}
// 前一个判断min(p1[1],p2[1])<P.y<=max(p1[1],p2[1])
// 后一个判断被测点 在 射线与边交点 的左边
if (
dcmp(p1[1] - y) > 0 !== dcmp(p2[1] - y) > 0 &&
dcmp(x - ((y - p1[1]) * (p1[0] - p2[0])) / (p1[1] - p2[1]) - p1[0]) < 0
) {
isHit = !isHit;
}
}
return isHit;
};
// 判断两个BBox是否相交
export const intersectBBox = (box1: Partial<IBBox>, box2: Partial<IBBox>) => {
return !(
box2.minX > box1.maxX ||
box2.maxX < box1.minX ||
box2.minY > box1.maxY ||
box2.maxY < box1.minY
);
};
const lineIntersectPolygon = (lines, line) => {
let isIntersect = false;
each(lines, (l) => {
if (getLineIntersect(l.from, l.to, line.from, line.to)) {
isIntersect = true;
return false;
}
});
return isIntersect;
};
/**
* polygon是否相交
* borrow from @antv/path-util
* @param points1 polygon1的顶点数组
* @param points2 polygon2的顶点数组
*/
export const isPolygonsIntersect = (points1: number[][], points2: number[][]): boolean => {
type BBox = Partial<IBBox>;
const getBBox = (points): BBox => {
const xArr = points.map((p) => p[0]);
const yArr = points.map((p) => p[1]);
return {
minX: Math.min.apply(null, xArr),
maxX: Math.max.apply(null, xArr),
minY: Math.min.apply(null, yArr),
maxY: Math.max.apply(null, yArr),
};
};
const parseToLines = (points: number[][]) => {
const lines = [];
const count = points.length;
for (let i = 0; i < count - 1; i++) {
const point = points[i];
const next = points[i + 1];
lines.push({
from: {
x: point[0],
y: point[1],
},
to: {
x: next[0],
y: next[1],
},
});
}
if (lines.length > 1) {
const first = points[0];
const last = points[count - 1];
lines.push({
from: {
x: last[0],
y: last[1],
},
to: {
x: first[0],
y: first[1],
},
});
}
return lines;
};
// 空数组,或者一个点返回 false
if (points1.length < 2 || points2.length < 2) {
return false;
}
const bbox1 = getBBox(points1);
const bbox2 = getBBox(points2);
// 判定包围盒是否相交,比判定点是否在多边形内要快的多,可以筛选掉大多数情况
if (!intersectBBox(bbox1, bbox2)) {
return false;
}
let isIn = false;
// 判定点是否在多边形内部,一旦有一个点在另一个多边形内,则返回
each(points2, (point) => {
if (isPointInPolygon(points1, point[0], point[1])) {
isIn = true;
return false;
}
});
if (isIn) {
return true;
}
each(points1, (point) => {
if (isPointInPolygon(points2, point[0], point[1])) {
isIn = true;
return false;
}
});
if (isIn) {
return true;
}
const lines1 = parseToLines(points1);
const lines2 = parseToLines(points2);
let isIntersect = false;
each(lines2, (line) => {
if (lineIntersectPolygon(lines1, line)) {
isIntersect = true;
return false;
}
});
return isIntersect;
};
export class Line {
public x1: number;
public y1: number;
public x2: number;
public y2: number;
constructor(x1: number, y1: number, x2: number, y2: number) {
this.x1 = x1;
this.y1 = y1;
this.x2 = x2;
this.y2 = y2;
}
public getBBox() {
const minX = Math.min(this.x1, this.x2);
const minY = Math.min(this.y1, this.y2);
const maxX = Math.max(this.x1, this.x2);
const maxY = Math.max(this.y1, this.y2);
const res = {
x: minX,
y: minY,
minX,
minY,
maxX,
maxY,
width: maxX - minX,
height: maxY - minY,
};
return res;
}
}
export const getBBoxBoundLine = (bbox: IBBox, direction: string) => {
const bounds = {
top: [bbox.minX, bbox.minY, bbox.maxX, bbox.minY],
left: [bbox.minX, bbox.minY, bbox.minX, bbox.maxY],
bottom: [bbox.minX, bbox.maxY, bbox.maxX, bbox.maxY],
right: [bbox.maxX, bbox.minY, bbox.maxX, bbox.maxY],
};
return bounds[direction];
};
/**
* 线线
*/
const fractionAlongLineA = (la: Line, lb: Line) => {
const uaT = (lb.x2 - lb.x1) * (la.y1 - lb.y1) - (lb.y2 - lb.y1) * (la.x1 - lb.x1);
const ubT = (la.x2 - la.x1) * (la.y1 - lb.y1) - (la.y2 - la.y1) * (la.x1 - lb.x1);
const uB = (lb.y2 - lb.y1) * (la.x2 - la.x1) - (lb.x2 - lb.x1) * (la.y2 - la.y1);
if (uB) {
const ua = uaT / uB;
const ub = ubT / uB;
if (ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1) {
return ua;
}
}
return Number.POSITIVE_INFINITY;
};
export const itemIntersectByLine = (item: Item, line: Line): [IPoint[], number] => {
const directions = ['top', 'left', 'bottom', 'right'];
const bbox = item.getBBox();
let countIntersections = 0;
const intersections = [];
for (let i = 0; i < 4; i++) {
const [x1, y1, x2, y2] = getBBoxBoundLine(bbox, directions[i]);
intersections[i] = getLineIntersect(
{ x: line.x1, y: line.y1 },
{ x: line.x2, y: line.y2 },
{ x: x1, y: y1 },
{ x: x2, y: y2 },
);
if (intersections[i]) {
countIntersections += 1;
}
}
return [intersections, countIntersections];
};
export const fractionToLine = (item: Item, line: Line) => {
const directions = ['top', 'left', 'bottom', 'right'];
const bbox = item.getBBox();
let minDistance = Number.POSITIVE_INFINITY;
let countIntersections = 0;
for (let i = 0; i < 4; i++) {
const [x1, y1, x2, y2] = getBBoxBoundLine(bbox, directions[i]);
let testDistance = fractionAlongLineA(line, new Line(x1, y1, x2, y2));
testDistance = Math.abs(testDistance - 0.5);
if (testDistance >= 0 && testDistance <= 1) {
countIntersections += 1;
minDistance = testDistance < minDistance ? testDistance : minDistance;
}
}
if (countIntersections === 0) return -1;
return minDistance;
};
export const getPointsCenter = (points: IPoint[]): IPoint => {
let centerX = 0;
let centerY = 0;
if (points.length > 0) {
for (const point of points) {
centerX += point.x;
centerY += point.y;
}
centerX /= points.length;
centerY /= points.length;
}
return { x: centerX, y: centerY };
};
export const squareDist = (a: IPoint, b: IPoint): number => {
return (a.x - b.x) ** 2 + (a.y - b.y) ** 2;
};
export const pointLineSquareDist = (point: IPoint, line: Line) => {
const x1 = line.x1;
const y1 = line.y1;
const x2 = line.x2 - x1;
const y2 = line.y2 - y1;
let px = point.x - x1;
let py = point.y - y1;
let dotprod = px * x2 + py * y2;
let projlenSq;
if (dotprod <= 0) {
projlenSq = 0;
} else {
px = x2 - px;
py = y2 - py;
dotprod = px * x2 + py * y2;
if (dotprod <= 0) {
projlenSq = 0;
} else {
projlenSq = (dotprod * dotprod) / (x2 * x2 + y2 * y2);
}
}
let lenSq = px * px + py * py - projlenSq;
if (lenSq < 0) {
lenSq = 0;
}
return lenSq;
};
export const isPointsOverlap = (p1, p2, e = 1e-3) => {
return (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2 < e ** 2;
};
/**
* 0
* @param point IPoint
* @param rect IRect
*/
export const pointRectSquareDist = (point: Point, rect: IRect) => {
const isLeft = point.x < rect.x
const isRight = point.x > rect.x + rect.width
const isTop = point.y > rect.y + rect.height
const isBottom = point.y < rect.y
const isPointOutside = isLeft || isRight || isTop || isBottom
if (!isPointOutside) {
return 0
}
if (isTop && !isLeft && !isRight) {
return (rect.y + rect.height - point.y) ** 2;
}
if (isBottom && !isLeft && !isRight) {
return (point.y - rect.y) ** 2;
}
if (isLeft && !isTop && !isBottom) {
return (rect.x - point.x) ** 2;
}
if (isRight && !isTop && !isBottom) {
return (rect.x + rect.width - point.x) ** 2;
}
const dx = Math.min(Math.abs(rect.x - point.x), Math.abs(rect.x + rect.width - point.x))
const dy = Math.min(Math.abs(rect.y - point.y), Math.abs(rect.y + rect.height - point.y))
return dx * dx + dy * dy;
};

View File

@ -0,0 +1,319 @@
import { vec2 } from '@antv/matrix-util';
import { catmullRom2Bezier } from '@antv/path-util';
import { IPoint } from '../types';
/**
*
* @param {String} str
* @param {Object} o json data
*/
const substitute = (str: string, o: any): string => {
if (!str || !o) {
return str;
}
return str.replace(/\\?\{([^{}]+)\}/g, (match: string, name: string) => {
if (match.charAt(0) === '\\') {
return match.slice(1);
}
let res = o[name];
if (res === 0) res = '0';
return res || '';
});
};
/**
* 线 M C
* @param points coordinate set
*/
export const getSpline = (points: IPoint[]) => {
const data: number[] = [];
if (points.length < 2) {
throw new Error(`point length must largn than 2, now it's ${points.length}`);
}
for (const point of points) {
const { x, y } = point;
data.push(x);
data.push(y);
}
const spliePath = catmullRom2Bezier(data);
spliePath.unshift(['M', points[0].x, points[0].y]);
return spliePath;
};
/**
*
* @param {IPoint} startPoint x,y
* @param {IPoint} endPoint , x,y
* @param {Number} percent , 0-1
* @param {Number} offset
* @return {IPoint} x,y
*/
export const getControlPoint = (
startPoint: IPoint,
endPoint: IPoint,
percent: number = 0,
offset: number = 0,
): IPoint => {
const point: IPoint = {
x: (1 - percent) * startPoint.x + percent * endPoint.x,
y: (1 - percent) * startPoint.y + percent * endPoint.y,
};
let tangent: number[] = [];
vec2.normalize(tangent, [endPoint.x - startPoint.x, endPoint.y - startPoint.y]);
if (tangent.length === 0) {
tangent = [0, 0];
}
const perpendicular = [-tangent[1] * offset, tangent[0] * offset]; // 垂直向量
point.x += perpendicular[0];
point.y += perpendicular[1];
return point;
};
/**
* Path多边形
* @param {Array} points
* @param {Boolen} z
* @return {Array} Path
*/
export const pointsToPolygon = (points: IPoint[], z?: boolean): string => {
const { length } = points;
if (!length) {
return '';
}
let path = '';
let str = '';
for (let i = 0; i < length; i++) {
const item = points[i];
if (i === 0) {
str = 'M{x} {y}';
} else {
str = 'L{x} {y}';
}
path += substitute(str, item);
}
if (z) {
path += 'Z';
}
return path;
};
export const pathToPoints = (path: any[]) => {
const points = [];
path.forEach((seg) => {
const command = seg[0];
if (command !== 'A') {
for (let i = 1; i < seg.length; i = i + 2) {
points.push([seg[i], seg[i + 1]]);
}
} else {
const length = seg.length;
points.push([seg[length - 2], seg[length - 1]]);
}
});
return points;
};
/**
* 线
* @param points
*/
export const getClosedSpline = (points: IPoint[]) => {
if (points.length < 2) {
throw new Error(`point length must largn than 2, now it's ${points.length}`);
}
const first = points[0];
const second = points[1];
const last = points[points.length - 1];
const lastSecond = points[points.length - 2];
points.unshift(last);
points.unshift(lastSecond);
points.push(first);
points.push(second);
const closedPath = [];
for (let i = 1; i < points.length - 2; i += 1) {
const x0 = points[i - 1].x;
const y0 = points[i - 1].y;
const x1 = points[i].x;
const y1 = points[i].y;
const x2 = points[i + 1].x;
const y2 = points[i + 1].y;
const x3 = i !== points.length - 2 ? points[i + 2].x : x2;
const y3 = i !== points.length - 2 ? points[i + 2].y : y2;
const cp1x = x1 + (x2 - x0) / 6;
const cp1y = y1 + (y2 - y0) / 6;
const cp2x = x2 - (x3 - x1) / 6;
const cp2y = y2 - (y3 - y1) / 6;
closedPath.push(['C', cp1x, cp1y, cp2x, cp2y, x2, y2]);
}
closedPath.unshift(['M', last.x, last.y]);
return closedPath;
};
const vecScaleTo = (v: number[], length: number) => {
// Vector with direction of v with specified length
return vec2.scale([], vec2.normalize([], v), length);
};
const unitNormal = (p0: number[], p1: number[]) => {
// Returns the unit normal to the line segment from p0 to p1.
const n = [p0[1] - p1[1], p1[0] - p0[0]];
const nLength = Math.sqrt(n[0] * n[0] + n[1] * n[1]);
if (nLength === 0) {
throw new Error('p0 should not be equal to p1');
}
return [n[0] / nLength, n[1] / nLength];
};
const vecFrom = (p0: number[], p1: number[]) => {
// Vector from p0 to p1
return [p1[0] - p0[0], p1[1] - p0[1]];
};
/**
*
* @param polyPoints
* @param padding
*/
export function roundedHull(polyPoints: number[][], padding: number) {
// The rounded hull path around a single point
const roundedHull1 = (points: number[][]) => {
const p1 = [points[0][0], points[0][1] - padding];
const p2 = [points[0][0], points[0][1] + padding];
return `M ${p1} A ${padding},${padding},0,0,0,${p2} A ${padding},${padding},0,0,0,${p1}`;
};
// The rounded hull path around two points
const roundedHull2 = (points: number[][]) => {
const offsetVector = vec2.scale([], unitNormal(points[0], points[1]), padding);
const invOffsetVector = vec2.scale([], offsetVector, -1);
const p0 = vec2.add([], points[0], offsetVector);
const p1 = vec2.add([], points[1], offsetVector);
const p2 = vec2.add([], points[1], invOffsetVector);
const p3 = vec2.add([], points[0], invOffsetVector);
return `M ${p0} L ${p1} A ${[padding, padding, '0,0,0', p2].join(',')} L ${p3} A ${[
padding,
padding,
'0,0,0',
p0,
].join(',')}`;
};
// 特殊情况处理节点数小于等于2
if (!polyPoints || polyPoints.length < 1) return '';
if (polyPoints.length === 1) return roundedHull1(polyPoints);
if (polyPoints.length === 2) return roundedHull2(polyPoints);
let segments = new Array(polyPoints.length);
// Calculate each offset (outwards) segment of the convex hull.
for (let segmentIndex = 0; segmentIndex < segments.length; ++segmentIndex) {
const p0 =
segmentIndex === 0 ? polyPoints[polyPoints.length - 1] : polyPoints[segmentIndex - 1];
const p1 = polyPoints[segmentIndex];
// Compute the offset vector for the line segment, with length = padding.
const offset = vec2.scale([], unitNormal(p0, p1), padding);
segments[segmentIndex] = [vec2.add([], p0, offset), vec2.add([], p1, offset)];
}
const arcData = `A ${[padding, padding, '0,0,0,'].join(',')}`;
segments = segments.map((segment, index) => {
let pathFragment = '';
if (index === 0) {
pathFragment = `M ${segments[segments.length - 1][1]} `;
}
pathFragment += `${arcData + segment[0]} L ${segment[1]}`;
return pathFragment;
});
return segments.join(' ');
}
/**
*
* @param polyPoints
* @param padding
*/
export function paddedHull(polyPoints: number[][], padding: number) {
const pointCount = polyPoints.length;
const smoothHull1 = (points) => {
// Returns the path for a circular hull around a single point.
const p1 = [points[0][0], points[0][1] - padding];
const p2 = [points[0][0], points[0][1] + padding];
return `M ${p1} A ${[padding, padding, '0,0,0', p2].join(',')} A ${[
padding,
padding,
'0,0,0',
p1,
].join(',')}`;
};
// Returns the path for a rounded hull around two points.
const smoothHull2 = (points) => {
const v = vecFrom(points[0], points[1]);
const extensionVec = vecScaleTo(v, padding);
const extension0 = vec2.add([], points[0], vec2.scale([], extensionVec, -1));
const extension1 = vec2.add([], points[1], extensionVec);
const tangentHalfLength = 1.2 * padding;
const controlDelta = vecScaleTo(vec2.normalize([], v), tangentHalfLength);
const invControlDelta = vec2.scale([], controlDelta, -1);
const control0 = vec2.add([], extension0, invControlDelta);
const control1 = vec2.add([], extension1, invControlDelta);
const control3 = vec2.add([], extension0, controlDelta);
return `M ${extension0} C ${[control0, control1, extension1].join(',')} S ${[
control3,
extension0,
].join(',')} Z`;
};
// Handle special cases
if (!polyPoints || pointCount < 1) return '';
if (pointCount === 1) return smoothHull1(polyPoints);
if (pointCount === 2) return smoothHull2(polyPoints);
const hullPoints = polyPoints.map((point, index) => {
const pNext = polyPoints[(index + 1) % pointCount];
return {
p: point,
v: vec2.normalize([], vecFrom(point, pNext)),
};
});
// Compute the expanded hull points, and the nearest prior control point for each.
for (let i = 0; i < hullPoints.length; ++i) {
const priorIndex = i > 0 ? i - 1 : pointCount - 1;
const extensionVec = vec2.normalize(
[],
vec2.add([], hullPoints[priorIndex].v, vec2.scale([], hullPoints[i].v, -1)),
);
hullPoints[i].p = vec2.add([], hullPoints[i].p, vec2.scale([], extensionVec, padding));
}
return hullPoints.map((obj) => {
const point = obj.p;
return { x: point[0], y: point[1] };
});
}

95446
packages/core/stats.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,232 @@
import G6, { Algorithm } from '../../../src';
const { adjMatrix } = Algorithm;
const div = document.createElement('div');
div.id = 'container';
document.body.appendChild(div);
const data = {
nodes: [
{
id: 'A',
label: '0',
},
{
id: 'B',
label: '1',
},
{
id: 'C',
label: '2',
},
{
id: 'D',
label: '3',
},
{
id: 'E',
label: '4',
},
{
id: 'F',
label: '5',
},
{
id: 'G',
label: '6',
},
{
id: 'H',
label: '7',
},
],
edges: [
{
source: 'A',
target: 'B',
},
{
source: 'B',
target: 'C',
},
{
source: 'C',
target: 'G',
},
{
source: 'A',
target: 'D',
},
{
source: 'A',
target: 'E',
},
{
source: 'E',
target: 'F',
},
{
source: 'F',
target: 'D',
},
],
};
describe('Adjacency Matrix on graph', () => {
const graph = new G6.Graph({
container: 'container',
width: 500,
height: 500,
defaultEdge: {
style: {
endArrow: true,
},
},
});
graph.data(data);
graph.render();
it('get graph adjacency matrix', () => {
const matrix = graph.getAdjMatrix();
expect(Object.keys(matrix).length).toBe(8);
const node0Adj = matrix[0];
expect(node0Adj.length).toBe(5);
expect(node0Adj[0]).toBe(undefined);
expect(node0Adj[1]).toBe(1);
expect(node0Adj[2]).toBe(undefined);
expect(node0Adj[3]).toBe(1);
expect(node0Adj[4]).toBe(1);
});
it('add items and cache', () => {
graph.addItem('node', {
id: 'I',
label: '8',
});
graph.addItem('edge', {
source: 'I',
target: 'A',
});
graph.addItem('edge', {
source: 'C',
target: 'A',
});
// use the cache
const cachedMatrix = graph.getAdjMatrix();
expect(Object.keys(cachedMatrix).length).toBe(8);
const cachedNode0Adj = cachedMatrix[0];
expect(cachedNode0Adj.length).toBe(5);
expect(cachedNode0Adj[0]).toBe(undefined);
expect(cachedNode0Adj[1]).toBe(1);
expect(cachedNode0Adj[2]).toBe(undefined);
expect(cachedNode0Adj[3]).toBe(1);
expect(cachedNode0Adj[4]).toBe(1);
// do not use the cache
const matrix = graph.getAdjMatrix(false);
expect(Object.keys(matrix).length).toBe(9);
const node0Adj = matrix[0];
expect(node0Adj.length).toBe(9);
expect(node0Adj[0]).toBe(undefined);
expect(node0Adj[1]).toBe(1);
expect(node0Adj[2]).toBe(1);
expect(node0Adj[3]).toBe(1);
expect(node0Adj[4]).toBe(1);
expect(node0Adj[5]).toBe(undefined);
expect(node0Adj[6]).toBe(undefined);
expect(node0Adj[7]).toBe(undefined);
expect(node0Adj[8]).toBe(1);
});
it('directed', () => {
// do not use the cache and directed
const matrix = graph.getAdjMatrix(false, true);
expect(Object.keys(matrix).length).toBe(9);
const node0Adj = matrix[0];
expect(node0Adj.length).toBe(5);
expect(node0Adj[0]).toBe(undefined);
expect(node0Adj[1]).toBe(1);
expect(node0Adj[2]).toBe(undefined);
expect(node0Adj[3]).toBe(1);
expect(node0Adj[4]).toBe(1);
const node8Adj = matrix[8];
expect(node8Adj.length).toBe(1);
expect(node8Adj[0]).toBe(1);
graph.destroy();
});
});
describe('Adjacency Matrix by Algorithm', () => {
const graph = new G6.Graph({
container: 'container',
width: 500,
height: 500,
defaultEdge: {
style: {
endArrow: true,
},
},
});
graph.data(data);
graph.render();
it('get graph adjacency matrix', () => {
const matrix = adjMatrix(graph);
expect(Object.keys(matrix).length).toBe(8);
const node0Adj = matrix[0];
expect(node0Adj.length).toBe(5);
expect(node0Adj[0]).toBe(undefined);
expect(node0Adj[1]).toBe(1);
expect(node0Adj[2]).toBe(undefined);
expect(node0Adj[3]).toBe(1);
expect(node0Adj[4]).toBe(1);
});
it('add items and cache', () => {
graph.addItem('node', {
id: 'I',
label: '8',
});
graph.addItem('edge', {
source: 'I',
target: 'A',
});
graph.addItem('edge', {
source: 'C',
target: 'A',
});
const matrix = adjMatrix(graph);
expect(Object.keys(matrix).length).toBe(9);
const node0Adj = matrix[0];
expect(node0Adj.length).toBe(9);
expect(node0Adj[0]).toBe(undefined);
expect(node0Adj[1]).toBe(1);
expect(node0Adj[2]).toBe(1);
expect(node0Adj[3]).toBe(1);
expect(node0Adj[4]).toBe(1);
expect(node0Adj[5]).toBe(undefined);
expect(node0Adj[6]).toBe(undefined);
expect(node0Adj[7]).toBe(undefined);
expect(node0Adj[8]).toBe(1);
});
it('directed', () => {
// directed
const matrix = adjMatrix(graph, true);
expect(Object.keys(matrix).length).toBe(9);
const node0Adj = matrix[0];
expect(node0Adj.length).toBe(5);
expect(node0Adj[0]).toBe(undefined);
expect(node0Adj[1]).toBe(1);
expect(node0Adj[2]).toBe(undefined);
expect(node0Adj[3]).toBe(1);
expect(node0Adj[4]).toBe(1);
const node8Adj = matrix[8];
expect(node8Adj.length).toBe(1);
expect(node8Adj[0]).toBe(1);
graph.destroy();
});
});

View File

@ -0,0 +1,201 @@
import G6, { Algorithm } from '../../../src';
const { breadthFirstSearch } = Algorithm;
const div = document.createElement('div');
div.id = 'container';
document.body.appendChild(div);
const data = {
nodes: [
{
id: 'A',
},
{
id: 'B',
},
{
id: 'C',
},
{
id: 'D',
},
{
id: 'E',
},
{
id: 'F',
},
{
id: 'G',
},
{
id: 'H',
},
],
edges: [
{
source: 'A',
target: 'B',
},
{
source: 'B',
target: 'C',
},
{
source: 'C',
target: 'G',
},
{
source: 'A',
target: 'D',
},
{
source: 'A',
target: 'E',
},
{
source: 'E',
target: 'F',
},
{
source: 'F',
target: 'D',
},
],
};
describe('breadthFirstSearch', () => {
it('should perform BFS operation on graph', () => {
const enterNodeCallback = jest.fn();
const leaveNodeCallback = jest.fn();
const graph = new G6.Graph({
container: 'container',
width: 500,
height: 500,
// layout
});
graph.data(data);
graph.render();
// Traverse graphs without callbacks first.
breadthFirstSearch(graph, 'A');
// Traverse graph with enterNode and leaveNode callbacks.
breadthFirstSearch(graph, 'A', {
enter: enterNodeCallback,
leave: leaveNodeCallback,
});
expect(enterNodeCallback).toHaveBeenCalledTimes(7);
expect(leaveNodeCallback).toHaveBeenCalledTimes(7);
const nodeA = graph.findById('A');
const nodeB = graph.findById('B');
const nodeC = graph.findById('C');
const nodeD = graph.findById('D');
const nodeE = graph.findById('E');
const nodeF = graph.findById('F');
const nodeG = graph.findById('G');
const enterNodeParamsMap = [
{ currentNode: nodeA, previousNode: null },
{ currentNode: nodeB, previousNode: nodeA },
{ currentNode: nodeD, previousNode: nodeB },
{ currentNode: nodeE, previousNode: nodeD },
{ currentNode: nodeC, previousNode: nodeE },
{ currentNode: nodeF, previousNode: nodeC },
{ currentNode: nodeG, previousNode: nodeF },
];
for (let callIndex = 0; callIndex < 6; callIndex += 1) {
const params = enterNodeCallback.mock.calls[callIndex][0];
expect(params.current.get('id')).toEqual(enterNodeParamsMap[callIndex].currentNode.get('id'));
expect(params.previous && params.previous.get('id')).toEqual(
enterNodeParamsMap[callIndex].previousNode &&
enterNodeParamsMap[callIndex].previousNode.get('id'),
);
}
const leaveNodeParamsMap = [
{ currentNode: nodeA, previousNode: null },
{ currentNode: nodeB, previousNode: nodeA },
{ currentNode: nodeD, previousNode: nodeB },
{ currentNode: nodeE, previousNode: nodeD },
{ currentNode: nodeC, previousNode: nodeE },
{ currentNode: nodeF, previousNode: nodeC },
{ currentNode: nodeG, previousNode: nodeF },
];
for (let callIndex = 0; callIndex < 6; callIndex += 1) {
const params = leaveNodeCallback.mock.calls[callIndex][0];
expect(params.current.get('id')).toEqual(leaveNodeParamsMap[callIndex].currentNode.get('id'));
expect(params.previous && params.previous.get('id')).toEqual(
leaveNodeParamsMap[callIndex].previousNode &&
leaveNodeParamsMap[callIndex].previousNode.get('id'),
);
}
graph.destroy();
});
it('should allow to create custom node visiting logic', () => {
const graph = new G6.Graph({
container: 'container',
width: 500,
height: 500,
// layout
});
graph.data(data);
graph.render();
const enterNodeCallback = jest.fn();
const leaveNodeCallback = jest.fn();
// Traverse graph with enterNode and leaveNode callbacks.
breadthFirstSearch(graph, 'A', {
enter: enterNodeCallback,
leave: leaveNodeCallback,
allowTraversal: ({ current, next }) => {
return !(current.get('id') === 'A' && next.get('id') === 'B');
},
});
expect(enterNodeCallback).toHaveBeenCalledTimes(5);
expect(leaveNodeCallback).toHaveBeenCalledTimes(5);
const enterNodeParamsMap = [
{ currentNode: 'A', previousNode: null },
{ currentNode: 'D', previousNode: 'A' },
{ currentNode: 'E', previousNode: 'D' },
{ currentNode: 'F', previousNode: 'E' },
{ currentNode: 'D', previousNode: 'F' },
];
for (let callIndex = 0; callIndex < 5; callIndex += 1) {
const params = enterNodeCallback.mock.calls[callIndex][0];
expect(params.current.get('id')).toEqual(enterNodeParamsMap[callIndex].currentNode);
expect(params.previous && params.previous.get('id')).toEqual(
enterNodeParamsMap[callIndex].previousNode,
);
}
const leaveNodeParamsMap = [
{ currentNode: 'A', previousNode: null },
{ currentNode: 'D', previousNode: 'A' },
{ currentNode: 'E', previousNode: 'D' },
{ currentNode: 'F', previousNode: 'E' },
{ currentNode: 'D', previousNode: 'F' },
];
for (let callIndex = 0; callIndex < 5; callIndex += 1) {
const params = leaveNodeCallback.mock.calls[callIndex][0];
expect(params.current.get('id')).toEqual(leaveNodeParamsMap[callIndex].currentNode);
expect(params.previous && params.previous.get('id')).toEqual(
leaveNodeParamsMap[callIndex].previousNode,
);
}
});
});

View File

@ -0,0 +1,140 @@
import G6, { Algorithm } from '../../../src';
const { getConnectedComponents } = Algorithm;
const div = document.createElement('div');
div.id = 'container';
document.body.appendChild(div);
const data = {
nodes: [
{
id: 'A',
},
{
id: 'B',
},
{
id: 'C',
},
{
id: 'D',
},
{
id: 'E',
},
{
id: 'F',
},
{
id: 'G',
},
{
id: 'H',
},
],
edges: [
{
source: 'A',
target: 'B',
},
{
source: 'B',
target: 'C',
},
{
source: 'A',
target: 'C',
},
{
source: 'D',
target: 'A',
},
{
source: 'D',
target: 'E',
},
{
source: 'E',
target: 'F',
},
{
source: 'F',
target: 'D',
},
{
source: 'G',
target: 'H',
},
{
source: 'H',
target: 'G',
},
],
};
data.nodes.forEach((d) => {
d['label'] = d.id;
});
describe('find connected components', () => {
const graph = new G6.Graph({
container: 'container',
width: 500,
height: 400,
layout: {
type: 'force',
},
modes: {
default: ['zoom-canvas', 'drag-canvas', 'drag-node'],
},
defaultNode: {
type: 'node',
labelCfg: {
style: {
fill: '#fff',
fontSize: 14,
},
},
},
defaultEdge: {
style: {
endArrow: true,
},
},
});
graph.data(data);
graph.render();
it('detect strongly connected components in undirected graph', () => {
let result = getConnectedComponents(graph, false);
expect(result.length).toEqual(2);
expect(result[0].map((node) => node.get('id')).sort()).toEqual(['A', 'B', 'C', 'D', 'E', 'F']);
expect(result[1].map((node) => node.get('id')).sort()).toEqual(['G', 'H']);
});
it('detect strongly connected components in directed graph', () => {
let result = getConnectedComponents(graph, true);
expect(result.length).toEqual(5);
expect(result[3].map((node) => node.get('id')).sort()).toEqual(['D', 'E', 'F']);
expect(result[4].map((node) => node.get('id')).sort()).toEqual(['G', 'H']);
});
it('test connected components detection performance using large graph', () => {
fetch('https://gw.alipayobjects.com/os/basement_prod/da5a1b47-37d6-44d7-8d10-f3e046dabf82.json')
.then((res) => res.json())
.then((data) => {
data.nodes.forEach((node) => {
node.label = node.olabel;
node.degree = 0;
data.edges.forEach((edge) => {
if (edge.source === node.id || edge.target === node.id) {
node.degree++;
}
});
});
graph.changeData(data);
let directedComps = getConnectedComponents(graph, true);
let undirectedComps = getConnectedComponents(graph, false);
expect(directedComps.length).toEqual(1589);
expect(undirectedComps.length).toEqual(396);
graph.destroy();
});
});
});

View File

@ -0,0 +1,550 @@
import G6, { Algorithm } from '../../../src';
const { detectDirectedCycle, detectAllCycles } = Algorithm;
const div = document.createElement('div');
div.id = 'container';
document.body.appendChild(div);
const data = {
nodes: [
{
id: 'A',
},
{
id: 'B',
},
{
id: 'C',
},
{
id: 'D',
},
{
id: 'E',
},
{
id: 'F',
},
{
id: 'G',
},
],
edges: [
{
source: 'A',
target: 'B',
},
{
source: 'B',
target: 'C',
},
{
source: 'A',
target: 'C',
},
{
source: 'D',
target: 'A',
},
{
source: 'D',
target: 'E',
},
{
source: 'E',
target: 'F',
},
],
};
data.nodes.forEach((d) => {
d['label'] = d.id;
});
describe('detectDirectedCycle', () => {
const graph = new G6.Graph({
container: 'container',
width: 500,
height: 500,
defaultNode: {
type: 'node',
labelCfg: {
style: {
fill: '#333',
fontSize: 12,
},
},
},
defaultEdge: {
style: {
endArrow: true,
},
},
modes: { default: ['drag-node', 'drag-canvas', 'zoom-canvas', 'lasso-select'] },
});
graph.data(data);
graph.render();
it('should detect directed cycle', () => {
let result = detectDirectedCycle(graph);
// debugger
expect(result).toBeNull();
data.edges.push({
source: 'F',
target: 'D',
});
graph.changeData(data);
// 返回格式:
// { currentNodeId: prevNode }
result = detectDirectedCycle(graph);
const nodeF = graph.findById('F');
const nodeD = graph.findById('D');
const nodeE = graph.findById('E');
expect(result).toEqual({
D: nodeF,
F: nodeE,
E: nodeD,
});
});
it('detect all cycles in directed graph', () => {
data.edges.push({
source: 'C',
target: 'D',
});
graph.changeData(data);
graph.render();
const result = detectAllCycles(graph, true);
expect(result.length).toEqual(3);
const nodeA = graph.findById('A');
const nodeB = graph.findById('B');
const nodeC = graph.findById('C');
const nodeD = graph.findById('D');
const result2 = detectAllCycles(graph, true, ['B']);
expect(result2.length).toEqual(1);
expect(result2[0]).toEqual({
A: nodeB,
B: nodeC,
C: nodeD,
D: nodeA,
});
});
it('detect cycle in undirected graph', () => {
const result = detectAllCycles(graph);
expect(result.length).toEqual(3);
const result2 = detectAllCycles(graph, false, ['B'], false);
expect(Object.keys(result2[0]).sort()).toEqual(['D', 'E', 'F']);
});
it('test another graph', () => {
const data = {
nodes: [
{
id: '0',
label: '0',
},
{
id: '1',
label: '1',
},
{
id: '2',
label: '2',
},
{
id: '3',
label: '3',
},
{
id: '4',
label: '4',
},
{
id: '5',
label: '5',
},
{
id: '6',
label: '6',
},
{
id: '7',
label: '7',
},
{
id: '8',
label: '8',
},
{
id: '9',
label: '9',
},
{
id: '10',
label: '10',
},
{
id: '11',
label: '11',
},
{
id: '12',
label: '12',
},
{
id: '13',
label: '13',
},
{
id: '14',
label: '14',
},
{
id: '15',
label: '15',
},
{
id: '16',
label: '16',
},
{
id: '17',
label: '17',
},
{
id: '18',
label: '18',
},
{
id: '19',
label: '19',
},
{
id: '20',
label: '20',
},
{
id: '21',
label: '21',
},
{
id: '22',
label: '22',
},
{
id: '23',
label: '23',
},
{
id: '24',
label: '24',
},
{
id: '25',
label: '25',
},
{
id: '26',
label: '26',
},
{
id: '27',
label: '27',
},
{
id: '28',
label: '28',
},
{
id: '29',
label: '29',
},
{
id: '30',
label: '30',
},
{
id: '31',
label: '31',
},
{
id: '32',
label: '32',
},
{
id: '33',
label: '33',
},
],
edges: [
{
source: '0',
target: '1',
},
{
source: '0',
target: '2',
},
{
source: '3',
target: '0',
},
{
source: '0',
target: '4',
},
{
source: '5',
target: '0',
},
{
source: '0',
target: '7',
},
{
source: '0',
target: '8',
},
{
source: '0',
target: '9',
},
{
source: '0',
target: '10',
},
{
source: '0',
target: '11',
},
{
source: '0',
target: '13',
},
{
source: '14',
target: '0',
},
{
source: '0',
target: '15',
},
{
source: '0',
target: '16',
},
{
source: '2',
target: '3',
},
{
source: '4',
target: '5',
},
{
source: '4',
target: '6',
},
{
source: '5',
target: '6',
},
{
source: '7',
target: '13',
},
{
source: '8',
target: '14',
},
{
source: '9',
target: '10',
},
{
source: '10',
target: '22',
},
{
source: '10',
target: '14',
},
{
source: '10',
target: '12',
},
{
source: '10',
target: '24',
},
{
source: '10',
target: '21',
},
{
source: '10',
target: '20',
},
{
source: '11',
target: '24',
},
{
source: '11',
target: '22',
},
{
source: '11',
target: '14',
},
{
source: '12',
target: '13',
},
{
source: '16',
target: '17',
},
{
source: '16',
target: '18',
},
{
source: '16',
target: '21',
},
{
source: '16',
target: '22',
},
{
source: '17',
target: '18',
},
{
source: '17',
target: '20',
},
{
source: '18',
target: '19',
},
{
source: '19',
target: '20',
},
{
source: '19',
target: '33',
},
{
source: '19',
target: '22',
},
{
source: '19',
target: '23',
},
{
source: '20',
target: '21',
},
{
source: '21',
target: '22',
},
{
source: '22',
target: '24',
},
{
source: '22',
target: '25',
},
{
source: '22',
target: '26',
},
{
source: '22',
target: '23',
},
{
source: '22',
target: '28',
},
{
source: '22',
target: '30',
},
{
source: '22',
target: '31',
},
{
source: '22',
target: '32',
},
{
source: '22',
target: '33',
},
{
source: '23',
target: '28',
},
{
source: '23',
target: '27',
},
{
source: '23',
target: '29',
},
{
source: '23',
target: '30',
},
{
source: '23',
target: '31',
},
{
source: '23',
target: '33',
},
{
source: '32',
target: '33',
},
],
};
graph.changeData(data);
const result = detectAllCycles(graph, true, ['14']);
const result2 = detectAllCycles(graph);
expect(result.length).toEqual(4);
expect(result2.length).toEqual(27);
});
it('test a large graph', () => {
fetch('https://gw.alipayobjects.com/os/basement_prod/da5a1b47-37d6-44d7-8d10-f3e046dabf82.json')
.then((res) => res.json())
.then((data) => {
data.nodes.forEach((node) => {
node.label = node.olabel;
node.degree = 0;
data.edges.forEach((edge) => {
if (edge.source === node.id || edge.target === node.id) {
node.degree++;
}
});
});
graph.changeData(data);
const directedCycles = detectAllCycles(graph, true);
expect(directedCycles.length).toEqual(0);
const undirectedCycles = detectAllCycles(graph, false, ['1084'], false);
expect(undirectedCycles.length).toEqual(1548);
});
});
});

View File

@ -0,0 +1,197 @@
import G6, { Algorithm } from '../../../src';
const { depthFirstSearch } = Algorithm;
const div = document.createElement('div');
div.id = 'container';
document.body.appendChild(div);
const data = {
nodes: [
{
id: 'A',
},
{
id: 'B',
},
{
id: 'C',
},
{
id: 'D',
},
{
id: 'E',
},
{
id: 'F',
},
{
id: 'G',
},
],
edges: [
{
source: 'A',
target: 'B',
},
{
source: 'B',
target: 'C',
},
{
source: 'C',
target: 'G',
},
{
source: 'A',
target: 'D',
},
{
source: 'A',
target: 'E',
},
{
source: 'E',
target: 'F',
},
{
source: 'F',
target: 'D',
},
{
source: 'D',
target: 'G',
},
],
};
describe('depthFirstSearch', () => {
it('should perform DFS operation on graph', () => {
const graph = new G6.Graph({
container: 'container',
width: 500,
height: 500,
// layout
});
graph.data(data);
graph.render();
const enterNodeCallback = jest.fn();
const leaveNodeCallback = jest.fn();
// Traverse graphs without callbacks first to check default ones.
depthFirstSearch(graph, 'A');
// Traverse graph with enterNode and leaveNode callbacks.
depthFirstSearch(graph, 'A', {
enter: enterNodeCallback,
leave: leaveNodeCallback,
});
expect(enterNodeCallback).toHaveBeenCalledTimes(graph.getNodes().length);
expect(leaveNodeCallback).toHaveBeenCalledTimes(graph.getNodes().length);
const enterNodeParamsMap = [
{ currentNode: 'A', previousNode: null },
{ currentNode: 'B', previousNode: 'A' },
{ currentNode: 'C', previousNode: 'B' },
{ currentNode: 'G', previousNode: 'C' },
{ currentNode: 'D', previousNode: 'A' },
{ currentNode: 'E', previousNode: 'A' },
{ currentNode: 'F', previousNode: 'E' },
];
for (let callIndex = 0; callIndex < graph.getNodes().length; callIndex += 1) {
const params = enterNodeCallback.mock.calls[callIndex][0];
expect(params.current.get('id')).toEqual(enterNodeParamsMap[callIndex].currentNode);
expect(params.previous && params.previous.get('id')).toEqual(
enterNodeParamsMap[callIndex].previousNode,
);
}
const leaveNodeParamsMap = [
{ currentNode: 'G', previousNode: 'C' },
{ currentNode: 'C', previousNode: 'B' },
{ currentNode: 'B', previousNode: 'A' },
{ currentNode: 'D', previousNode: 'A' },
{ currentNode: 'F', previousNode: 'E' },
{ currentNode: 'E', previousNode: 'A' },
{ currentNode: 'A', previousNode: null },
];
for (let callIndex = 0; callIndex < graph.getNodes().length; callIndex += 1) {
const params = leaveNodeCallback.mock.calls[callIndex][0];
expect(params.current.get('id')).toEqual(leaveNodeParamsMap[callIndex].currentNode);
expect(params.previous && params.previous.get('id')).toEqual(
leaveNodeParamsMap[callIndex].previousNode,
);
}
graph.destroy();
});
it('allow users to redefine node visiting logic', () => {
const graph = new G6.Graph({
container: 'container',
width: 500,
height: 500,
// layout
});
graph.data(data);
graph.render();
const enterNodeCallback = jest.fn();
const leaveNodeCallback = jest.fn();
depthFirstSearch(graph, 'A', {
enter: enterNodeCallback,
leave: leaveNodeCallback,
allowTraversal: ({ current: currentNode, next: nextNode }) => {
return !(currentNode.get('id') === 'A' && nextNode.get('id') === 'B');
},
});
expect(enterNodeCallback).toHaveBeenCalledTimes(7);
expect(leaveNodeCallback).toHaveBeenCalledTimes(7);
const enterNodeParamsMap = [
{ currentNode: 'A', previousNode: null },
{ currentNode: 'D', previousNode: 'A' },
{ currentNode: 'G', previousNode: 'D' },
{ currentNode: 'E', previousNode: 'A' },
{ currentNode: 'F', previousNode: 'E' },
{ currentNode: 'D', previousNode: 'F' },
{ currentNode: 'G', previousNode: 'D' },
];
for (let callIndex = 0; callIndex < graph.getNodes().length; callIndex += 1) {
const params = enterNodeCallback.mock.calls[callIndex][0];
expect(params.current.get('id')).toEqual(enterNodeParamsMap[callIndex].currentNode);
expect(params.previous && params.previous.get('id')).toEqual(
enterNodeParamsMap[callIndex].previousNode,
);
}
const leaveNodeParamsMap = [
{ currentNode: 'G', previousNode: 'D' },
{ currentNode: 'D', previousNode: 'A' },
{ currentNode: 'G', previousNode: 'D' },
{ currentNode: 'D', previousNode: 'F' },
{ currentNode: 'F', previousNode: 'E' },
{ currentNode: 'E', previousNode: 'A' },
{ currentNode: 'A', previousNode: null },
];
for (let callIndex = 0; callIndex < graph.getNodes().length; callIndex += 1) {
const params = leaveNodeCallback.mock.calls[callIndex][0];
expect(params.current.get('id')).toEqual(leaveNodeParamsMap[callIndex].currentNode);
expect(params.previous && params.previous.get('id')).toEqual(
leaveNodeParamsMap[callIndex].previousNode,
);
}
graph.destroy();
});
});

View File

@ -0,0 +1,116 @@
import G6, { Algorithm } from '../../../src';
const { findAllPath, findShortestPath } = Algorithm;
const div = document.createElement('div');
div.id = 'container';
document.body.appendChild(div);
const data = {
nodes: [
{
id: 'A',
label: 'A',
},
{
id: 'B',
label: 'B',
},
{
id: 'C',
label: 'C',
},
{
id: 'D',
label: 'D',
},
{
id: 'E',
label: 'E',
},
{
id: 'F',
label: 'F',
},
{
id: 'G',
label: 'G',
},
{
id: 'H',
label: 'H',
},
],
edges: [
{
source: 'A',
target: 'B',
},
{
source: 'B',
target: 'C',
},
{
source: 'C',
target: 'G',
},
{
source: 'A',
target: 'D',
},
{
source: 'A',
target: 'E',
},
{
source: 'E',
target: 'F',
},
{
source: 'F',
target: 'D',
},
{
source: 'D',
target: 'E',
},
],
};
describe('Shortest Path from source to target on graph', () => {
const graph = new G6.Graph({
container: 'container',
width: 500,
height: 500,
defaultEdge: {
style: {
endArrow: true,
},
},
modes: {
default: ['zoom-canvas', 'drag-canvas', 'drag-node'],
},
});
graph.data(data);
graph.render();
it('find the shortest path', () => {
const { length, path } = findShortestPath(graph, 'A', 'C');
expect(length).toBe(2);
expect(path).toStrictEqual(['A', 'B', 'C']);
});
it('find all paths', () => {
const allPaths = findAllPath(graph, 'A', 'E');
expect(allPaths.length).toBe(3);
expect(allPaths[0]).toStrictEqual(['A', 'D', 'F', 'E']);
expect(allPaths[1]).toStrictEqual(['A', 'D', 'E']);
expect(allPaths[2]).toStrictEqual(['A', 'E']);
});
it('find all paths in directed graph', () => {
const allPaths = findAllPath(graph, 'A', 'E', true);
expect(allPaths.length).toStrictEqual(2);
expect(allPaths[0]).toStrictEqual(['A', 'D', 'E']);
expect(allPaths[1]).toStrictEqual(['A', 'E']);
});
});

View File

@ -0,0 +1,220 @@
import G6, { Algorithm } from '../../../src';
import { GraphData } from '../../../src/types';
const { labelPropagation } = Algorithm;
const div = document.createElement('div');
div.id = 'container';
document.body.appendChild(div);
const data1: GraphData = {
nodes: [
{
id: 'A',
},
{
id: 'B',
},
{
id: 'C',
},
{
id: 'D',
},
{
id: 'E',
},
{
id: 'F',
},
{
id: 'G',
},
{
id: 'H',
},
{
id: 'I',
},
],
edges: [
{
source: 'A',
target: 'B',
},
{
source: 'A',
target: 'G',
},
{
source: 'B',
target: 'C',
},
{
source: 'C',
target: 'G',
},
{
source: 'A',
target: 'D',
},
{
source: 'A',
target: 'E',
},
{
source: 'E',
target: 'F',
},
{
source: 'F',
target: 'D',
},
{
source: 'H',
target: 'I',
},
{
source: 'A',
target: 'I',
},
],
};
let colorMap = {
g2: '#BDD2FD',
g19: '#BDEFDB',
g36: '#F6C3B7',
g46: '#FFD8B8',
g48: '#D3C6EA',
g53: '#CCC',
g57: '#a00',
g67: '#0a0',
g75: '#00a',
g76: '#000',
};
const colorArray = [
'rgb(91, 143, 249)',
'rgb(90, 216, 166)',
'rgb(93, 112, 146)',
'rgb(246, 189, 22)',
'rgb(232, 104, 74)',
'rgb(109, 200, 236)',
'rgb(146, 112, 202)',
'rgb(255, 157, 77)',
'rgb(38, 154, 153)',
'rgb(227, 137, 163)',
];
describe('label propagation', () => {
it('simple label propagation', () => {
const data: GraphData = {
nodes: [
{ id: '0' }, { id: '1' }, { id: '2' }, { id: '3' }, { id: '4' },
{ id: '5' }, { id: '6' }, { id: '7' }, { id: '8' }, { id: '9' },
{ id: '10' }, { id: '11' }, { id: '12' }, { id: '13' }, { id: '14' },
],
edges: [
{ source: '0', target: '1' }, { source: '0', target: '2' }, { source: '0', target: '3' }, { source: '0', target: '4' },
{ source: '1', target: '2' }, { source: '1', target: '3' }, { source: '1', target: '4' },
{ source: '2', target: '3' }, { source: '2', target: '4' },
{ source: '3', target: '4' },
{ source: '0', target: '0' },
{ source: '0', target: '0' },
{ source: '0', target: '0' },
{ source: '5', target: '6', weight: 5 }, { source: '5', target: '7' }, { source: '5', target: '8' }, { source: '5', target: '9' },
{ source: '6', target: '7' }, { source: '6', target: '8' }, { source: '6', target: '9' },
{ source: '7', target: '8' }, { source: '7', target: '9' },
{ source: '8', target: '9' },
{ source: '10', target: '11' }, { source: '10', target: '12' }, { source: '10', target: '13' }, { source: '10', target: '14' },
{ source: '11', target: '12' }, { source: '11', target: '13' }, { source: '11', target: '14' },
{ source: '12', target: '13' }, { source: '12', target: '14' },
{ source: '13', target: '14', weight: 5 },
{ source: '0', target: '5' },
{ source: '5', target: '10' },
{ source: '10', target: '0' },
{ source: '10', target: '0' },
]
}
const clusteredData = labelPropagation(data, false, 'weight');
expect(clusteredData.clusters.length).not.toBe(0);
expect(clusteredData.clusterEdges.length).not.toBe(0);
// to show the graph
const clusterMap = {};
data.nodes.forEach(node => {
clusterMap[node.clusterId as string] = Object.keys(clusterMap).length - 1;
});
data.nodes.forEach(node => {
node.label = `${node.id}`;
node.style = {
fill: colorArray[clusterMap[node.clusterId as string] % colorArray.length]
}
})
const graph = new G6.Graph({
container: 'container',
width: 500,
height: 500,
modes: {
default: ['drag-node', 'drag-canvas']
},
layout: {
type: 'force'
}
});
graph.data(data);
graph.render();
graph.destroy();
});
it('label propagation with large graph', () => { // https://gw.alipayobjects.com/os/antvdemo/assets/data/relations.json
fetch('https://gw.alipayobjects.com/os/basement_prod/da5a1b47-37d6-44d7-8d10-f3e046dabf82.json')
.then((res) => res.json())
.then((data) => { // 1589 nodes, 2747 edges
const t0 = performance.now();
const clusteredData = labelPropagation(data, false, 'weight');
const t1 = performance.now();
// console.log(`Call to doSomething took ${t1 - t0} milliseconds.`);
// 9037.91999999521 ms
expect(clusteredData.clusters.length).toBe(472);
expect(clusteredData.clusterEdges.length).toBe(444);
const clusterMap = {};
data.nodes.forEach(node => {
clusterMap[node.clusterId] = Object.keys(clusterMap).length - 1;
});
data.nodes.forEach(node => {
node.label = `${node.clusterId}`;
node.style = {
fill: colorArray[clusterMap[node.clusterId] % colorArray.length]
}
})
const graph = new G6.Graph({
container: 'container',
width: 1500,
height: 1500,
modes: {
default: ['drag-node', 'drag-canvas']
},
defaultNode: {
labelCfg: {
position: 'right'
}
}
});
graph.data(data);
graph.render();
graph.destroy();
});
});
});

View File

@ -0,0 +1,286 @@
import LinkedList, { LinkedListNode } from '../../../src/algorithm/structs/linked-list';
describe('LinkedListNode', () => {
it('create list node with value', () => {
const node = new LinkedListNode(1 as any);
expect(node.value).toBe(1);
expect(node.next).toBeNull();
});
it('crate list node with object', () => {
const nodeValue = {
value: 1,
key: 'test',
};
const node = new LinkedListNode(nodeValue as any);
expect(node.value.value).toBe(1);
expect(node.value.key).toBe('test');
expect(node.next).toBeNull();
});
it('should link nodes together', () => {
const node2 = new LinkedListNode(2);
const node1 = new LinkedListNode(1, node2);
expect(node1.next).toBeDefined();
expect(node2.next).toBeNull();
expect(node1.value).toBe(1);
expect(node1.next.value).toBe(2);
});
it('should convert node to string', () => {
const node = new LinkedListNode(1);
expect(node.toString()).toBe('1');
node.value = 'string value';
expect(node.toString()).toBe('string value');
});
it('should convert node to string with custom stringifier', () => {
const nodeValue = { value: 1, key: 'test' };
const node = new LinkedListNode(nodeValue);
const toStringCallback = (value) => `value: ${value.value}, key: ${value.key}`;
expect(node.toString(toStringCallback)).toBe('value: 1, key: test');
});
});
describe('LinkedList', () => {
it('should create empty linked list', () => {
const linkedList = new LinkedList();
expect(linkedList.toString()).toBe('');
});
it('should append node to linked list', () => {
const linkedList = new LinkedList();
expect(linkedList.head).toBeNull();
expect(linkedList.tail).toBeNull();
linkedList.append(1);
linkedList.append(2);
expect(linkedList.toString()).toBe('1,2');
expect(linkedList.tail.next).toBeNull();
});
it('should prepend node to linked list', () => {
const linkedList = new LinkedList();
linkedList.prepend(2);
expect(linkedList.head.toString()).toBe('2');
expect(linkedList.tail.toString()).toBe('2');
linkedList.append(1);
linkedList.prepend(3);
expect(linkedList.toString()).toBe('3,2,1');
});
it('should delete node by value from linked list', () => {
const linkedList = new LinkedList();
expect(linkedList.delete(5)).toBeNull();
linkedList.append(1);
linkedList.append(1);
linkedList.append(2);
linkedList.append(3);
linkedList.append(3);
linkedList.append(3);
linkedList.append(4);
linkedList.append(5);
expect(linkedList.head.toString()).toBe('1');
expect(linkedList.tail.toString()).toBe('5');
const deletedNode = linkedList.delete(3);
expect(deletedNode.value).toBe(3);
expect(linkedList.toString()).toBe('1,1,2,4,5');
linkedList.delete(3);
expect(linkedList.toString()).toBe('1,1,2,4,5');
linkedList.delete(1);
expect(linkedList.toString()).toBe('2,4,5');
expect(linkedList.head.toString()).toBe('2');
expect(linkedList.tail.toString()).toBe('5');
linkedList.delete(5);
expect(linkedList.toString()).toBe('2,4');
expect(linkedList.head.toString()).toBe('2');
expect(linkedList.tail.toString()).toBe('4');
linkedList.delete(4);
expect(linkedList.toString()).toBe('2');
expect(linkedList.head.toString()).toBe('2');
expect(linkedList.tail.toString()).toBe('2');
linkedList.delete(2);
expect(linkedList.toString()).toBe('');
});
it('should delete linked list tail', () => {
const linkedList = new LinkedList();
linkedList.append(1);
linkedList.append(2);
linkedList.append(3);
expect(linkedList.head.toString()).toBe('1');
expect(linkedList.tail.toString()).toBe('3');
const deletedNode1 = linkedList.deleteTail();
expect(deletedNode1.value).toBe(3);
expect(linkedList.toString()).toBe('1,2');
expect(linkedList.head.toString()).toBe('1');
expect(linkedList.tail.toString()).toBe('2');
const deletedNode2 = linkedList.deleteTail();
expect(deletedNode2.value).toBe(2);
expect(linkedList.toString()).toBe('1');
expect(linkedList.head.toString()).toBe('1');
expect(linkedList.tail.toString()).toBe('1');
const deletedNode3 = linkedList.deleteTail();
expect(deletedNode3.value).toBe(1);
expect(linkedList.toString()).toBe('');
expect(linkedList.head).toBeNull();
expect(linkedList.tail).toBeNull();
});
it('should delete linked list head', () => {
const linkedList = new LinkedList();
expect(linkedList.deleteHead()).toBeNull();
linkedList.append(1);
linkedList.append(2);
expect(linkedList.head.toString()).toBe('1');
expect(linkedList.tail.toString()).toBe('2');
const deletedNode1 = linkedList.deleteHead();
expect(deletedNode1.value).toBe(1);
expect(linkedList.toString()).toBe('2');
expect(linkedList.head.toString()).toBe('2');
expect(linkedList.tail.toString()).toBe('2');
const deletedNode2 = linkedList.deleteHead();
expect(deletedNode2.value).toBe(2);
expect(linkedList.toString()).toBe('');
expect(linkedList.head).toBeNull();
expect(linkedList.tail).toBeNull();
});
it('should be possible to store objects in the list and to print them out', () => {
const linkedList = new LinkedList();
const nodeValue1 = { value: 1, key: 'key1' };
const nodeValue2 = { value: 2, key: 'key2' };
linkedList.append(nodeValue1).prepend(nodeValue2);
const nodeStringifier = (value) => `${value.key}:${value.value}`;
expect(linkedList.toString(nodeStringifier)).toBe('key2:2,key1:1');
});
it('should find node by value', () => {
const linkedList = new LinkedList();
expect(linkedList.find({ value: 5 })).toBeNull();
linkedList.append(1);
expect(linkedList.find({ value: 1 })).toBeDefined();
linkedList.append(2).append(3);
const node = linkedList.find({ value: 2 });
expect(node.value).toBe(2);
expect(linkedList.find({ value: 5 })).toBeNull();
});
it('should find node by callback', () => {
const linkedList = new LinkedList();
linkedList
.append({ value: 1, key: 'test1' })
.append({ value: 2, key: 'test2' })
.append({ value: 3, key: 'test3' });
const node = linkedList.find({ callback: (value) => value.key === 'test2' });
expect(node).toBeDefined();
expect(node.value.value).toBe(2);
expect(node.value.key).toBe('test2');
expect(linkedList.find({ callback: (value) => value.key === 'test5' })).toBeNull();
});
it('should create linked list from array', () => {
const linkedList = new LinkedList();
linkedList.fromArray([1, 1, 2, 3, 3, 3, 4, 5]);
expect(linkedList.toString()).toBe('1,1,2,3,3,3,4,5');
});
it('should find node by means of custom compare function', () => {
const comparatorFunction = (a, b) => {
if (a.customValue === b.customValue) {
return 0;
}
return a.customValue < b.customValue ? -1 : 1;
};
const linkedList = new LinkedList(comparatorFunction);
linkedList
.append({ value: 1, customValue: 'test1' })
.append({ value: 2, customValue: 'test2' })
.append({ value: 3, customValue: 'test3' });
const node = linkedList.find({
value: { value: 2, customValue: 'test2' },
});
expect(node).toBeDefined();
expect(node.value.value).toBe(2);
expect(node.value.customValue).toBe('test2');
expect(linkedList.find({ value: 2, customValue: 'test5' })).toBeNull();
});
it('should reverse linked list', () => {
const linkedList = new LinkedList();
// Add test values to linked list.
linkedList.append(1).append(2).append(3);
expect(linkedList.toString()).toBe('1,2,3');
expect(linkedList.head.value).toBe(1);
expect(linkedList.tail.value).toBe(3);
// Reverse linked list.
linkedList.reverse();
expect(linkedList.toString()).toBe('3,2,1');
expect(linkedList.head.value).toBe(3);
expect(linkedList.tail.value).toBe(1);
// Reverse linked list back to initial state.
linkedList.reverse();
expect(linkedList.toString()).toBe('1,2,3');
expect(linkedList.head.value).toBe(1);
expect(linkedList.tail.value).toBe(3);
});
});

View File

@ -0,0 +1,221 @@
import G6, { Algorithm } from '../../../src';
import { GraphData } from '../../../src/types';
const { louvain } = Algorithm;
const div = document.createElement('div');
div.id = 'container';
document.body.appendChild(div);
const data1: GraphData = {
nodes: [
{
id: 'A',
},
{
id: 'B',
},
{
id: 'C',
},
{
id: 'D',
},
{
id: 'E',
},
{
id: 'F',
},
{
id: 'G',
},
{
id: 'H',
},
{
id: 'I',
},
],
edges: [
{
source: 'A',
target: 'B',
},
{
source: 'A',
target: 'G',
},
{
source: 'B',
target: 'C',
},
{
source: 'C',
target: 'G',
},
{
source: 'A',
target: 'D',
},
{
source: 'A',
target: 'E',
},
{
source: 'E',
target: 'F',
},
{
source: 'F',
target: 'D',
},
{
source: 'H',
target: 'I',
},
{
source: 'A',
target: 'I',
},
],
};
let colorMap = {
g2: '#BDD2FD',
g19: '#BDEFDB',
g36: '#F6C3B7',
g46: '#FFD8B8',
g48: '#D3C6EA',
g53: '#CCC',
g57: '#a00',
g67: '#0a0',
g75: '#00a',
g76: '#000',
};
const colorArray = [
'rgb(91, 143, 249)',
'rgb(90, 216, 166)',
'rgb(93, 112, 146)',
'rgb(246, 189, 22)',
'rgb(232, 104, 74)',
'rgb(109, 200, 236)',
'rgb(146, 112, 202)',
'rgb(255, 157, 77)',
'rgb(38, 154, 153)',
'rgb(227, 137, 163)',
];
describe('louvain', () => {
it('simple louvain', () => {
const data: GraphData = {
nodes: [
{ id: '0' }, { id: '1' }, { id: '2' }, { id: '3' }, { id: '4' },
{ id: '5' }, { id: '6' }, { id: '7' }, { id: '8' }, { id: '9' },
{ id: '10' }, { id: '11' }, { id: '12' }, { id: '13' }, { id: '14' },
],
edges: [
{ source: '0', target: '1' }, { source: '0', target: '2' }, { source: '0', target: '3' }, { source: '0', target: '4' },
{ source: '1', target: '2' }, { source: '1', target: '3' }, { source: '1', target: '4' },
{ source: '2', target: '3' }, { source: '2', target: '4' },
{ source: '3', target: '4' },
{ source: '0', target: '0' },
{ source: '0', target: '0' },
{ source: '0', target: '0' },
{ source: '5', target: '6', weight: 5 }, { source: '5', target: '7' }, { source: '5', target: '8' }, { source: '5', target: '9' },
{ source: '6', target: '7' }, { source: '6', target: '8' }, { source: '6', target: '9' },
{ source: '7', target: '8' }, { source: '7', target: '9' },
{ source: '8', target: '9' },
{ source: '10', target: '11' }, { source: '10', target: '12' }, { source: '10', target: '13' }, { source: '10', target: '14' },
{ source: '11', target: '12' }, { source: '11', target: '13' }, { source: '11', target: '14' },
{ source: '12', target: '13' }, { source: '12', target: '14' },
{ source: '13', target: '14', weight: 5 },
{ source: '0', target: '5' },
{ source: '5', target: '10' },
{ source: '10', target: '0' },
{ source: '10', target: '0' },
]
}
const clusteredData = louvain(data, false, 'weight');
expect(clusteredData.clusters.length).toBe(3);
expect(clusteredData.clusters[0].sumTot).toBe(3);
expect(clusteredData.clusters[1].sumTot).toBe(2);
expect(clusteredData.clusterEdges.length).toBe(6);
expect(clusteredData.clusterEdges[0].count).toBe(13);
expect(clusteredData.clusterEdges[1].count).toBe(10);
expect(clusteredData.clusterEdges[1].weight).toBe(14);
// to show the graph
const clusterMap = {};
data.nodes.forEach(node => {
clusterMap[node.clusterId as string] = Object.keys(clusterMap).length - 1;
});
data.nodes.forEach(node => {
node.label = `${node.id}`;
node.style = {
fill: colorArray[clusterMap[node.clusterId as string] % colorArray.length]
}
})
const graph = new G6.Graph({
container: 'container',
width: 500,
height: 500,
modes: {
default: ['drag-node', 'drag-canvas']
},
layout: {
type: 'force'
}
});
graph.data(data);
graph.render();
});
it('louvain with large graph', () => { // https://gw.alipayobjects.com/os/antvdemo/assets/data/relations.json
fetch('https://gw.alipayobjects.com/os/basement_prod/da5a1b47-37d6-44d7-8d10-f3e046dabf82.json')
.then((res) => res.json())
.then((data) => { // 1589 nodes, 2747 edges
const t0 = performance.now();
const clusteredData = louvain(data, false, 'weight');
const t1 = performance.now();
// console.log(`Call to doSomething took ${t1 - t0} milliseconds.`);
expect(clusteredData.clusters.length).toBe(495);
expect(clusteredData.clusterEdges.length).toBe(505);
const clusterMap = {};
data.nodes.forEach(node => {
clusterMap[node.clusterId] = Object.keys(clusterMap).length - 1;
});
data.nodes.forEach(node => {
node.label = `${node.clusterId}`;
node.style = {
fill: colorArray[clusterMap[node.clusterId] % colorArray.length]
}
})
const graph = new G6.Graph({
container: 'container',
width: 1500,
height: 1500,
modes: {
default: ['drag-node', 'drag-canvas']
},
defaultNode: {
labelCfg: {
position: 'right'
}
}
});
graph.data(data);
graph.render();
});
});
});

View File

@ -0,0 +1,129 @@
import G6, { Algorithm } from '../../../src';
const { minimumSpanningTree } = Algorithm;
const div = document.createElement('div');
div.id = 'container';
document.body.appendChild(div);
const data = {
nodes: [
{
id: 'A',
},
{
id: 'B',
},
{
id: 'C',
},
{
id: 'D',
},
{
id: 'E',
},
{
id: 'F',
},
{
id: 'G',
},
],
edges: [
{
source: 'A',
target: 'B',
weight: 1,
},
{
source: 'B',
target: 'C',
weight: 1,
},
{
source: 'A',
target: 'C',
weight: 2,
},
{
source: 'D',
target: 'A',
weight: 3,
},
{
source: 'D',
target: 'E',
weight: 4,
},
{
source: 'E',
target: 'F',
weight: 2,
},
{
source: 'F',
target: 'D',
weight: 3,
},
],
};
data.nodes.forEach((node) => (node['label'] = node.id));
data.edges.forEach((edge) => (edge['label'] = edge.weight));
describe('minimumSpanningTree', () => {
const graph = new G6.Graph({
container: 'container',
width: 500,
height: 500,
layout: {
type: 'force',
},
modes: {
default: ['drag-node'],
},
defaultNode: {
labelCfg: {
style: {
fontSize: 12,
},
},
},
defaultEdge: {
style: {
endArrow: true,
},
labelCfg: {
style: {
fontSize: 12,
},
},
},
edgeStateStyles: {
mst: {
stroke: 'red',
},
},
});
graph.data(data);
graph.render();
it('test kruskal algorithm', () => {
let result = minimumSpanningTree(graph, 'weight');
let totalWeight = 0;
for (let edge of result) {
graph.setItemState(edge, 'mst', true);
totalWeight += edge.getModel()['weight'];
}
expect(totalWeight).toEqual(10);
});
it('test prim algorithm', () => {
let result = minimumSpanningTree(graph, 'weight', 'prim');
let totalWeight = 0;
for (let edge of result) {
graph.setItemState(edge, 'mst', true);
totalWeight += edge.getModel()['weight'];
}
expect(totalWeight).toEqual(10);
});
});

View File

@ -0,0 +1,157 @@
import G6, { Algorithm } from '../../../src';
const { getPageRank } = Algorithm;
const div = document.createElement('div');
div.id = 'container';
document.body.appendChild(div);
const data = {
nodes: [
{
id: 'A',
label: 'A',
},
{
id: 'B',
label: 'B',
},
{
id: 'C',
label: 'C',
},
{
id: 'D',
label: 'D',
},
{
id: 'E',
label: 'E',
},
{
id: 'F',
label: 'F',
},
{
id: 'G',
label: 'G',
},
{
id: 'H',
label: 'H',
},
{
id: 'I',
label: 'I',
},
{
id: 'J',
label: 'J',
},
{
id: 'K',
label: 'K',
}
],
edges: [
{
source: 'D',
target: 'A',
},
{
source: 'D',
target: 'B',
},
{
source: 'B',
target: 'C',
},
{
source: 'C',
target: 'B',
},
{
source: 'F',
target: 'B',
},
{
source: 'F',
target: 'E',
},
{
source: 'E',
target: 'F',
},
{
source: 'E',
target: 'D',
},
{
source: 'E',
target: 'B',
},
{
source: 'K',
target: 'E',
},
{
source: 'J',
target: 'E',
},
{
source: 'I',
target: 'E',
},
{
source: 'H',
target: 'E',
},
{
source: 'G',
target: 'E',
},
{
source: 'G',
target: 'B',
},
{
source: 'H',
target: 'B',
},
{
source: 'I',
target: 'B',
},
],
};
describe('Calculate pagerank of graph nodes', () => {
const graph = new G6.Graph({
container: 'container',
width: 500,
height: 500,
defaultEdge: {
style: {
endArrow: true,
},
},
modes: {
default: ['zoom-canvas', 'drag-canvas', 'drag-node'],
},
});
graph.data(data);
graph.render();
it('calculate pagerank', () => {
const result = getPageRank(graph);
let maxNodeId;
let maxVal = 0;
for (let nodeId in result) {
const val = result[nodeId];
if (val >= maxVal) {
maxNodeId = nodeId;
maxVal = val
}
}
expect(maxNodeId).toBe('B')
});
});

View File

@ -0,0 +1,65 @@
import Queue from '../../../src/algorithm/structs/queue';
describe('Queue', () => {
it('should create empty queue', () => {
const queue = new Queue();
expect(queue).not.toBeNull();
expect(queue.linkedList).not.toBeNull();
});
it('should enqueue data to queue', () => {
const queue = new Queue();
queue.enqueue(1);
queue.enqueue(2);
expect(queue.toString()).toBe('1,2');
});
it('should be possible to enqueue/dequeue objects', () => {
const queue = new Queue();
queue.enqueue({ value: 'test1', key: 'key1' });
queue.enqueue({ value: 'test2', key: 'key2' });
const stringifier = (value) => `${value.key}:${value.value}`;
expect(queue.toString(stringifier)).toBe('key1:test1,key2:test2');
expect(queue.dequeue().value).toBe('test1');
expect(queue.dequeue().value).toBe('test2');
});
it('should peek data from queue', () => {
const queue = new Queue();
expect(queue.peek()).toBeNull();
queue.enqueue(1);
queue.enqueue(2);
expect(queue.peek()).toBe(1);
expect(queue.peek()).toBe(1);
});
it('should check if queue is empty', () => {
const queue = new Queue();
expect(queue.isEmpty()).toBe(true);
queue.enqueue(1);
expect(queue.isEmpty()).toBe(false);
});
it('should dequeue from queue in FIFO order', () => {
const queue = new Queue();
queue.enqueue(1);
queue.enqueue(2);
expect(queue.dequeue()).toBe(1);
expect(queue.dequeue()).toBe(2);
expect(queue.dequeue()).toBeNull();
expect(queue.isEmpty()).toBe(true);
});
});

View File

@ -0,0 +1,258 @@
import G6, { Algorithm } from '../../../src';
const { floydWarshall } = Algorithm;
const div = document.createElement('div');
div.id = 'container';
document.body.appendChild(div);
const data = {
nodes: [
{
id: 'A',
label: '0',
},
{
id: 'B',
label: '1',
},
{
id: 'C',
label: '2',
},
{
id: 'D',
label: '3',
},
{
id: 'E',
label: '4',
},
{
id: 'F',
label: '5',
},
{
id: 'G',
label: '6',
},
{
id: 'H',
label: '7',
},
],
edges: [
{
source: 'A',
target: 'B',
},
{
source: 'B',
target: 'C',
},
{
source: 'C',
target: 'G',
},
{
source: 'A',
target: 'D',
},
{
source: 'A',
target: 'E',
},
{
source: 'E',
target: 'F',
},
{
source: 'F',
target: 'D',
},
],
};
describe('Shortest Path Matrix on graph', () => {
const graph = new G6.Graph({
container: 'container',
width: 500,
height: 500,
defaultEdge: {
style: {
endArrow: true,
},
},
});
graph.data(data);
graph.render();
it('get graph shortest path matrix', () => {
const matrix = graph.getShortestPathMatrix();
expect(Object.keys(matrix).length).toBe(8);
const node0 = matrix[0];
expect(node0.length).toBe(8);
expect(node0[0]).toBe(0);
expect(node0[1]).toBe(1);
expect(node0[2]).toBe(2);
expect(node0[3]).toBe(1);
expect(node0[4]).toBe(1);
expect(node0[5]).toBe(2);
expect(node0[6]).toBe(3);
expect(node0[7]).toBe(Infinity);
expect(matrix[1][7]).toBe(Infinity);
expect(matrix[2][7]).toBe(Infinity);
expect(matrix[3][7]).toBe(Infinity);
});
it('add items and cache', () => {
graph.addItem('node', {
id: 'I',
label: '8',
});
graph.addItem('edge', {
source: 'I',
target: 'A',
});
graph.addItem('edge', {
source: 'C',
target: 'A',
});
// use the cache
const cachedMatrix = graph.getShortestPathMatrix();
expect(Object.keys(cachedMatrix).length).toBe(8);
const cachedNode0 = cachedMatrix[0];
expect(cachedNode0.length).toBe(8);
expect(cachedNode0[0]).toBe(0);
expect(cachedNode0[1]).toBe(1);
expect(cachedNode0[2]).toBe(2);
expect(cachedNode0[3]).toBe(1);
expect(cachedNode0[4]).toBe(1);
expect(cachedNode0[5]).toBe(2);
expect(cachedNode0[6]).toBe(3);
expect(cachedNode0[7]).toBe(Infinity);
expect(cachedMatrix[1][7]).toBe(Infinity);
expect(cachedMatrix[2][7]).toBe(Infinity);
expect(cachedMatrix[3][7]).toBe(Infinity);
// do not use the cache
const matrix = graph.getShortestPathMatrix(false);
expect(Object.keys(matrix).length).toBe(9);
const node0 = matrix[0];
expect(node0.length).toBe(9);
expect(node0[0]).toBe(0);
expect(node0[1]).toBe(1);
expect(node0[2]).toBe(1);
expect(node0[3]).toBe(1);
expect(node0[4]).toBe(1);
expect(node0[5]).toBe(2);
expect(node0[6]).toBe(2);
expect(node0[7]).toBe(Infinity);
expect(node0[8]).toBe(1);
});
it('directed', () => {
// do not use the cache and directed
const matrix = graph.getShortestPathMatrix(false, true);
expect(Object.keys(matrix).length).toBe(9);
const node0 = matrix[0];
expect(node0.length).toBe(9);
expect(node0[0]).toBe(0);
expect(node0[1]).toBe(1);
expect(node0[2]).toBe(2);
expect(node0[3]).toBe(1);
expect(node0[4]).toBe(1);
expect(node0[5]).toBe(2);
expect(node0[6]).toBe(3);
expect(node0[7]).toBe(Infinity);
expect(node0[8]).toBe(Infinity);
const node8 = matrix[8];
expect(node8.length).toBe(9);
expect(node8[0]).toBe(1);
graph.destroy();
});
});
describe('Adjacency Matrix by Algorithm', () => {
const graph = new G6.Graph({
container: 'container',
width: 500,
height: 500,
defaultEdge: {
style: {
endArrow: true,
},
},
});
graph.data(data);
graph.render();
it('get graph shortestpath matrix', () => {
const matrix = floydWarshall(graph);
expect(Object.keys(matrix).length).toBe(8);
const node0 = matrix[0];
expect(node0.length).toBe(8);
expect(node0[0]).toBe(0);
expect(node0[1]).toBe(1);
expect(node0[2]).toBe(2);
expect(node0[3]).toBe(1);
expect(node0[4]).toBe(1);
expect(node0[5]).toBe(2);
expect(node0[6]).toBe(3);
expect(node0[7]).toBe(Infinity);
expect(matrix[1][7]).toBe(Infinity);
expect(matrix[2][7]).toBe(Infinity);
expect(matrix[3][7]).toBe(Infinity);
});
it('add items and cache', () => {
graph.addItem('node', {
id: 'I',
label: '8',
});
graph.addItem('edge', {
source: 'I',
target: 'A',
});
graph.addItem('edge', {
source: 'C',
target: 'A',
});
const matrix = floydWarshall(graph);
expect(Object.keys(matrix).length).toBe(9);
const node0 = matrix[0];
expect(node0.length).toBe(9);
expect(node0[0]).toBe(0);
expect(node0[1]).toBe(1);
expect(node0[2]).toBe(1);
expect(node0[3]).toBe(1);
expect(node0[4]).toBe(1);
expect(node0[5]).toBe(2);
expect(node0[6]).toBe(2);
expect(node0[7]).toBe(Infinity);
expect(node0[8]).toBe(1);
});
it('directed', () => {
// directed
const matrix = floydWarshall(graph, true);
expect(Object.keys(matrix).length).toBe(9);
const node0 = matrix[0];
expect(node0.length).toBe(9);
expect(node0[0]).toBe(0);
expect(node0[1]).toBe(1);
expect(node0[2]).toBe(2);
expect(node0[3]).toBe(1);
expect(node0[4]).toBe(1);
expect(node0[5]).toBe(2);
expect(node0[6]).toBe(3);
expect(node0[7]).toBe(Infinity);
expect(node0[8]).toBe(Infinity);
const node8 = matrix[8];
expect(node8.length).toBe(9);
expect(node8[0]).toBe(1);
graph.destroy();
});
});

View File

@ -0,0 +1,68 @@
import Stack from '../../../src/algorithm/structs/stack';
describe('stack unit test', () => {
it('init stack', () => {
const stack = new Stack();
for (let i = 0; i < 4; i++) {
stack.push({
nodes: [
{
id: `node${i}`,
},
],
});
}
const result = stack.pop();
// console.log(stack.toArray());
expect(result).toEqual({
nodes: [
{
id: 'node3',
},
],
});
expect(stack.peek()).toEqual({
nodes: [
{
id: 'node2',
},
],
});
expect(stack.isMaxStack()).toBe(false);
expect(stack.isEmpty()).toBe(false);
stack.push({
nodes: [
{
id: 'node5',
},
],
});
stack.push({
nodes: [
{
id: 'node6',
},
],
});
// console.log(stack.toArray());
expect(stack.isMaxStack()).toBe(false);
});
it('init stack with maxStep', () => {
const stack = new Stack(3);
for (let i = 0; i < 5; i++) {
stack.push({
nodes: [
{
id: `node${i}`,
},
],
});
}
expect(stack.length).toBe(3);
});
});

View File

@ -0,0 +1,294 @@
import '../../../src/behavior';
import '../../../src/shape';
import Graph from '../../../src/graph/graph';
describe('activate-relations', () => {
const div = document.createElement('div');
div.id = 'activate-relations-spec';
document.body.appendChild(div);
const graph = new Graph({
container: div,
width: 500,
height: 500,
modes: { default: [] },
defaultNode: {
style: {
stroke: '#f00',
},
},
nodeStateStyles: {
active: {
fillOpacity: 0.8,
lineWidth: 3,
},
selected: {
lineWidth: 5,
},
inactive: {
lineWidth: 1,
},
},
edgeStateStyles: {
active: {
strokeOpacity: 0.8,
lineWidth: 3,
},
inactive: {
lineWidth: 1,
},
},
});
const node1 = graph.addItem('node', { id: 'node1', x: 100, y: 100, label: 'node1' });
const node2 = graph.addItem('node', { id: 'node2', x: 200, y: 200, label: 'node2' });
graph.addItem('node', { id: 'node3', x: 80, y: 250, label: 'node3' });
graph.addItem('edge', { source: 'node1', target: 'node2' });
graph.addItem('edge', { source: 'node1', target: 'node3' });
it('default activate', (done) => {
graph.on('afteractivaterelations', (e) => {
const action = e.action;
if (e.item === node1) {
if (action === 'activate') {
const nodes = graph.findAllByState('node', 'active');
const edges = graph.findAllByState('edge', 'active');
expect(nodes.length).toEqual(3);
expect(edges.length).toEqual(2);
expect(graph.findAllByState('node', 'inactive').length).toEqual(0);
expect(graph.findAllByState('edge', 'inactive').length).toEqual(0);
const keyShape = node2.getKeyShape();
expect(keyShape.attr('fillOpacity')).toEqual(0.8);
} else {
const nodes = graph.findAllByState('node', 'active');
const edges = graph.findAllByState('edge', 'active');
expect(nodes.length).toEqual(0);
expect(edges.length).toEqual(0);
expect(graph.findAllByState('node', 'inactive').length).toEqual(0);
expect(graph.findAllByState('edge', 'inactive').length).toEqual(0);
}
} else {
if (action === 'activate') {
const nodes = graph.findAllByState('node', 'active');
const edges = graph.findAllByState('edge', 'active');
expect(nodes.length).toEqual(2);
expect(edges.length).toEqual(1);
expect(graph.findAllByState('node', 'inactive').length).toEqual(1);
expect(graph.findAllByState('edge', 'inactive').length).toEqual(1);
const keyShape = node1.getKeyShape();
expect(keyShape.attr('fillOpacity')).toEqual(0.8);
} else {
const nodes = graph.findAllByState('node', 'active');
const edges = graph.findAllByState('edge', 'active');
expect(nodes.length).toEqual(0);
expect(edges.length).toEqual(0);
expect(graph.findAllByState('node', 'inactive').length).toEqual(0);
expect(graph.findAllByState('edge', 'inactive').length).toEqual(0);
}
}
done();
});
graph.addBehaviors(['activate-relations'], 'default');
graph.emit('node:mouseenter', { item: node1 });
graph.emit('node:mouseleave', { item: node1 });
graph.emit('node:mouseenter', { item: node2 });
graph.emit('node:mouseleave', { item: node2 });
graph.removeBehaviors(['activate-relations'], 'default');
// graph.removeEvent();
graph.off('afteractivaterelations');
});
it('click to activate', (done) => {
graph.on('afteractivaterelations', (e) => {
const action = e.action;
if (e.item === node1) {
if (action === 'activate') {
const nodes = graph.findAllByState('node', 'active');
const edges = graph.findAllByState('edge', 'active');
expect(nodes.length).toEqual(3);
expect(edges.length).toEqual(2);
expect(graph.findAllByState('node', 'inactive').length).toEqual(0);
expect(graph.findAllByState('edge', 'inactive').length).toEqual(0);
const keyShape = node2.getKeyShape();
expect(keyShape.attr('fillOpacity')).toEqual(0.8);
} else {
const nodes = graph.findAllByState('node', 'active');
const edges = graph.findAllByState('edge', 'active');
expect(nodes.length).toEqual(0);
expect(edges.length).toEqual(0);
expect(graph.findAllByState('node', 'inactive').length).toEqual(0);
expect(graph.findAllByState('edge', 'inactive').length).toEqual(0);
}
} else {
if (action === 'activate') {
const nodes = graph.findAllByState('node', 'active');
const edges = graph.findAllByState('edge', 'active');
expect(nodes.length).toEqual(2);
expect(edges.length).toEqual(1);
expect(graph.findAllByState('node', 'inactive').length).toEqual(1);
expect(graph.findAllByState('edge', 'inactive').length).toEqual(1);
const keyShape = node1.getKeyShape();
expect(keyShape.attr('fillOpacity')).toEqual(0.8);
} else {
const nodes = graph.findAllByState('node', 'active');
const edges = graph.findAllByState('edge', 'active');
expect(nodes.length).toEqual(0);
expect(edges.length).toEqual(0);
expect(graph.findAllByState('node', 'inactive').length).toEqual(0);
expect(graph.findAllByState('edge', 'inactive').length).toEqual(0);
done();
}
}
});
graph.addBehaviors(
[
{
type: 'activate-relations',
trigger: 'click',
},
],
'default',
);
graph.emit('node:click', { item: node1 });
graph.emit('canvas:click', {});
graph.emit('node:click', { item: node2 });
graph.emit('canvas:click', {});
graph.removeBehaviors(['activate-relations'], 'default');
graph.off('afteractivaterelations');
});
it('custom state', (done) => {
const graph2 = new Graph({
container: div,
width: 500,
height: 500,
modes: { default: [] },
nodeStateStyles: {
highlight: {},
inactive: {},
active: {},
},
edgeStateStyles: {
highlight: {},
inactive: {},
active: {},
},
});
const g2node1 = graph2.addItem('node', { id: 'node1', x: 100, y: 100, label: 'node1' });
const g2node2 = graph2.addItem('node', { id: 'node2', x: 200, y: 200, label: 'node2' });
graph2.addItem('node', { id: 'node3', x: 80, y: 150, label: 'node3' });
graph2.addItem('edge', { source: 'node1', target: 'node2' });
graph2.addItem('edge', { source: 'node1', target: 'node3' });
graph2.on('afteractivaterelations', (e) => {
const action = e.action;
if (e.item === g2node1) {
if (action === 'activate') {
const nodes = graph2.findAllByState('node', 'highlight');
const edges = graph2.findAllByState('edge', 'highlight');
expect(nodes.length).toEqual(3);
expect(edges.length).toEqual(2);
expect(graph2.findAllByState('node', 'inactive').length).toEqual(0);
expect(graph2.findAllByState('edge', 'inactive').length).toEqual(0);
} else {
const nodes = graph2.findAllByState('node', 'highlight');
const edges = graph2.findAllByState('edge', 'highlight');
expect(nodes.length).toEqual(0);
expect(edges.length).toEqual(0);
}
} else {
if (action === 'activate') {
const nodes = graph2.findAllByState('node', 'highlight');
const edges = graph2.findAllByState('edge', 'highlight');
expect(nodes.length).toEqual(2);
expect(edges.length).toEqual(1);
expect(graph2.findAllByState('node', 'inactive').length).toEqual(0);
expect(graph2.findAllByState('edge', 'inactive').length).toEqual(0);
} else {
const nodes = graph2.findAllByState('node', 'highlight');
const edges = graph2.findAllByState('edge', 'highlight');
expect(nodes.length).toEqual(0);
expect(edges.length).toEqual(0);
done();
}
}
});
graph2.addBehaviors(
[
{
type: 'activate-relations',
activeState: 'highlight',
inactiveState: null,
},
],
'default',
);
graph2.emit('node:mouseenter', { item: g2node1 });
graph2.emit('node:mouseleave', { item: g2node1 });
graph2.emit('node:mouseenter', { item: g2node2 });
graph2.emit('node:mouseleave', { item: g2node2 });
graph2.destroy();
});
it('should not update', () => {
graph.addBehaviors(
[
{
type: 'activate-relations',
trigger: 'click',
shouldUpdate() {
return false;
},
},
],
'default',
);
graph.emit('node:click', { item: node1 });
let nodes = graph.findAllByState('node', 'active');
let edges = graph.findAllByState('edge', 'active');
expect(nodes.length).toEqual(0);
expect(edges.length).toEqual(0);
graph.emit('canvas:click', {});
graph.emit('node:click', { item: node2 });
nodes = graph.findAllByState('node', 'active');
edges = graph.findAllByState('edge', 'active');
expect(nodes.length).toEqual(0);
expect(edges.length).toEqual(0);
graph.emit('canvas:click', {});
graph.removeBehaviors(['activate-relations'], 'default');
graph.off('node:click');
graph.off('canvas:click');
});
it('combine selected state', () => {
graph.addBehaviors(
[
{
type: 'activate-relations',
trigger: 'mouseenter',
resetSelected: true,
},
],
'default',
);
graph.addBehaviors(
[
{
type: 'click-select',
},
],
'default',
);
graph.emit('node:click', { item: node1 });
let nodes = graph.findAllByState('node', 'selected');
expect(nodes.length).toEqual(1);
graph.emit('node:mouseenter', { item: node2 });
nodes = graph.findAllByState('node', 'selected');
expect(nodes.length).toEqual(0);
nodes = graph.findAllByState('node', 'active');
const edges = graph.findAllByState('edge', 'active');
expect(nodes.length).toEqual(2);
expect(edges.length).toEqual(1);
graph.emit('node:click', { item: node1 });
nodes = graph.findAllByState('node', 'selected');
expect(nodes.length).toEqual(1);
graph.emit('node:mouseleave', {});
graph.removeBehaviors(['activate-relations'], 'default');
graph.destroy();
});
});

View File

@ -0,0 +1,179 @@
import '../../../src/behavior';
import '../../../src/shape';
import Graph from '../../../src/graph/graph';
describe('brush-select', () => {
const div = document.createElement('div');
div.id = 'activate-relations-spec';
document.body.appendChild(div);
const graph = new Graph({
container: div,
width: 500,
height: 500,
modes: { default: ['zoom-canvas'] },
nodeStateStyles: {
selected: {},
customState: {},
},
edgeStateStyles: {
selected: {},
customState: {},
},
});
const node1 = graph.addItem('node', { id: 'node1', x: 100, y: 100, label: 'node1' });
const node2 = graph.addItem('node', { id: 'node2', x: 200, y: 200, label: 'node2' });
const node3 = graph.addItem('node', { id: 'node3', x: 80, y: 150, label: 'node3' });
graph.addItem('edge', { source: 'node1', target: 'node2' });
const edge1 = graph.addItem('edge', { source: 'node1', target: 'node3' });
it('default configs', () => {
graph.addBehaviors(['brush-select'], 'default');
graph.emit('keydown', { canvasX: 20, canvasY: 20, key: 'shift' });
graph.emit('dragstart', { canvasX: 20, canvasY: 20 });
graph.emit('drag', { canvasX: 120, canvasY: 120 });
// 只选中一个节点,没有边被选中
graph.emit('dragend', { canvasX: 120, canvasY: 120, x: 120, y: 120 });
let selectedNodes = graph.findAllByState('node', 'selected');
expect(selectedNodes.length).toEqual(1);
expect(selectedNodes[0] === node1).toBe(true);
let selectedEdges = graph.findAllByState('edge', 'selected');
expect(selectedEdges.length).toEqual(0);
// 选中两个节点,一条边
graph.emit('keydown', { canvasX: 20, canvasY: 20 });
graph.emit('dragstart', { canvasX: 20, canvasY: 20 });
graph.emit('dragend', { canvasX: 120, canvasY: 160, x: 120, y: 160 });
selectedNodes = graph.findAllByState('node', 'selected');
expect(selectedNodes.length).toEqual(2);
expect(selectedNodes[1] === node3).toBe(true);
selectedEdges = graph.findAllByState('edge', 'selected');
expect(selectedEdges.length).toEqual(1);
expect(selectedEdges[0] === edge1).toBe(true);
graph.emit('canvas:click');
selectedNodes = graph.findAllByState('node', 'selected');
expect(selectedNodes.length).toEqual(0);
selectedEdges = graph.findAllByState('edge', 'selected');
expect(selectedEdges.length).toEqual(0);
graph.translate(200, 200);
graph.emit('keydown', { canvasX: 20, canvasY: 20, key: 'abc' }); // invalid key
graph.emit('dragstart', { canvasX: 20, canvasY: 20 });
graph.emit('drag', { canvasX: 120, canvasY: 120 });
graph.emit('keyup', { key: 'shift' });
graph.emit('dragend', { canvasX: 120, canvasY: 120, x: -80, y: -80 });
selectedNodes = graph.findAllByState('node', 'selected');
expect(selectedNodes.length).toEqual(0);
graph.emit('keydown', { canvasX: 20, canvasY: 20, key: 'shift' });
graph.emit('dragstart', { canvasX: 20, canvasY: 20 });
graph.emit('drag', { canvasX: 120, canvasY: 120 });
graph.emit('keyup', { key: 'shift' });
selectedNodes = graph.findAllByState('node', 'selected');
expect(selectedNodes.length).toEqual(0);
// translate 到原点,以防影响后续的测试
graph.translate(-200, -200);
graph.removeBehaviors(['brush-select'], 'default');
});
it('modify cfgs', () => {
let triggered = false;
graph.translate(-200, -200);
graph.addBehaviors(
[
{
type: 'brush-select',
selectedState: 'customState',
includeEdges: false,
trigger: 'drag',
onSelect() {
triggered = true;
},
},
],
'default',
);
graph.emit('dragstart', { canvasX: -110, canvasY: -120 });
graph.emit('drag', { canvasX: 300, canvasY: 300 });
graph.emit('dragend', { canvasX: 300, canvasY: 300, x: 300, y: 300 });
let selectedNodes = graph.findAllByState('node', 'customState');
expect(selectedNodes.length).toEqual(2);
expect(selectedNodes[0] === node1).toBe(true);
expect(selectedNodes[1] === node2).toBe(true);
const selectedEdges = graph.findAllByState('edge', 'customState');
expect(selectedEdges.length).toEqual(0);
graph.emit('canvas:click');
selectedNodes = graph.findAllByState('node', 'customState');
expect(selectedNodes.length).toEqual(0);
expect(triggered).toBe(true);
graph.destroy();
});
it('invalid trigger, multiple is false', () => {
const graph = new Graph({
container: div,
width: 500,
height: 500,
modes: {
default: [
{
type: 'brush-select',
trigger: 'abc',
multiple: false,
},
],
},
});
graph.addItem('node', {
color: '#666',
x: 50,
y: 50,
size: 20,
style: { lineWidth: 2, fill: '#666' },
});
graph.paint();
expect(graph.get('modeController').currentBehaves[0].trigger).toEqual('shift');
graph.destroy();
});
it('manipulate on a node', () => {
const graph2 = new Graph({
container: div,
width: 500,
height: 500,
modes: {
default: [
{
type: 'brush-select',
trigger: 'drag',
},
],
},
nodeStateStyles: {
selected: {
stroke: '#ff0',
},
},
});
const node = graph2.addItem('node', {
color: '#666',
x: 50,
y: 50,
size: 20,
style: { lineWidth: 2, fill: '#666' },
});
graph2.paint();
graph2.emit('keydown', { canvasX: 50, canvasY: 50, key: 'shift' });
graph2.emit('dragstart', { canvasX: 50, canvasY: 50, item: node });
graph2.emit('drag', { canvasX: 100, canvasY: 100 });
graph2.emit('dragend', { canvasX: 100, canvasY: 100, x: 100, y: 100 });
const selectedNodes = graph2.findAllByState('node', 'selected');
expect(selectedNodes.length).toEqual(0);
graph2.destroy();
});
});

View File

@ -0,0 +1,373 @@
import '../../../src/behavior';
import '../../../src/shape';
import G6 from '../../../src';
import { ICombo, INode } from '../../../src/interface/item';
import { ComboConfig, NodeConfig } from '../../../src/types';
const div = document.createElement('div');
div.id = 'container';
document.body.appendChild(div);
const data = {
nodes: [
{
id: 'node1',
x: 150,
y: 150,
label: 'node1',
comboId: 'A',
},
{
id: 'node2',
x: 200,
y: 250,
label: 'node2',
comboId: 'A',
},
{
id: 'node3',
x: 100,
y: 250,
label: 'node3',
},
{
id: 'node4',
x: 200,
y: 350,
label: 'node4',
comboId: 'B',
},
],
edges: [
{
source: 'node1',
target: 'node4',
},
{
source: 'node1',
target: 'node2',
},
{
source: 'node2',
target: 'node3',
},
],
combos: [
{
id: 'A',
parentId: 'C',
label: 'gorup A',
type: 'circle',
},
{
id: 'B',
parentId: 'C',
label: 'gorup B',
type: 'circle',
},
{
id: 'C',
label: 'gorup C',
// type: 'rect'
},
{
id: 'F',
label: 'gorup F',
// type: 'rect'
},
{
id: 'G',
label: 'gorup G',
// parentId: 'F'
type: 'circle',
},
],
};
describe('collapse-expand-combo', () => {
it('default collapse expand combo', (done) => {
const graph = new G6.Graph({
container: 'container',
width: 1000,
height: 800,
modes: {
default: ['collapse-expand-combo'],
},
defaultCombo: {
type: 'circle',
style: {
fill: '#b5f5ec',
},
},
nodeStateStyles: {
selected: {
fill: 'red',
},
},
comboStateStyles: {
active: {
stroke: 'red',
},
selected: {
'text-shape': {
fill: '#f00',
fontSize: 20,
},
fill: '#36cfc9',
},
state2: {
stroke: '#0f0',
},
},
});
graph.data(data);
graph.render();
const comboA = graph.findById('A') as ICombo;
const comboC = graph.findById('C') as ICombo;
// collapse A and collapse B
graph.emit('combo:dblclick', { item: comboA });
graph.emit('combo:dblclick', { item: comboC });
expect(comboA.getModel().collapsed).toBe(true);
expect(comboC.getModel().collapsed).toBe(true);
comboA.getChildren().nodes.forEach((node) => {
expect(node.isVisible()).toBe(false);
});
comboA.getChildren().combos.forEach((combo) => {
expect(combo.isVisible()).toBe(false);
});
comboC.getChildren().nodes.forEach((node) => {
expect(node.isVisible()).toBe(false);
});
comboC.getChildren().combos.forEach((combo) => {
expect(combo.isVisible()).toBe(false);
});
setTimeout(() => {
// The console will warn: Fail to expand the combo since it's ancestor combo is collapsed.
graph.emit('combo:dblclick', { item: comboA });
expect(comboA.getModel().collapsed).toBe(true);
comboA.getChildren().nodes.forEach((node) => {
expect(node.isVisible()).toBe(false);
});
comboA.getChildren().combos.forEach((combo) => {
expect(combo.isVisible()).toBe(false);
});
graph.emit('combo:dblclick', { item: comboC });
expect(comboC.getModel().collapsed).toBe(false);
comboC.getChildren().nodes.forEach((node) => {
expect(node.isVisible()).toBe(true);
});
comboC.getChildren().combos.forEach((combo) => {
expect(combo.isVisible()).toBe(true);
});
// but A is still in collapsed state
expect(comboA.getModel().collapsed).toBe(true);
comboA.getChildren().nodes.forEach((node) => {
expect(node.isVisible()).toBe(false);
});
comboA.getChildren().combos.forEach((combo) => {
expect(combo.isVisible()).toBe(false);
});
graph.destroy();
done();
}, 250);
});
it('default collapsed set in data', (done) => {
data.combos.forEach((combo: ComboConfig) => {
combo.collapsed = true;
});
const graph = new G6.Graph({
container: 'container',
width: 1000,
height: 800,
});
graph.read(data);
setTimeout(() => {
graph.getNodes().forEach((node: INode) => {
if (node.get('id') !== 'node3') {
expect(node.isVisible()).toBe(false);
}
});
graph.destroy();
done();
}, 250);
});
it('collapse-expand-combo behavior with click trigger', (done) => {
data.combos.forEach((combo: ComboConfig) => {
delete combo.collapsed;
});
const graph = new G6.Graph({
container: 'container',
width: 1000,
height: 800,
modes: {
default: [
{
type: 'collapse-expand-combo',
trigger: 'click',
},
],
},
});
graph.read(data);
const comboA = graph.findById('A') as ICombo;
const comboC = graph.findById('C') as ICombo;
// collapse A and collapse B
graph.emit('combo:click', { item: comboA });
graph.emit('combo:click', { item: comboC });
expect(comboA.getModel().collapsed).toBe(true);
expect(comboC.getModel().collapsed).toBe(true);
comboA.getChildren().nodes.forEach((node) => {
expect(node.isVisible()).toBe(false);
});
comboA.getChildren().combos.forEach((combo) => {
expect(combo.isVisible()).toBe(false);
});
comboC.getChildren().nodes.forEach((node) => {
expect(node.isVisible()).toBe(false);
});
comboC.getChildren().combos.forEach((combo) => {
expect(combo.isVisible()).toBe(false);
});
setTimeout(() => {
// The console will warn: Fail to expand the combo since it's ancestor combo is collapsed.
graph.emit('combo:click', { item: comboA });
expect(comboA.getModel().collapsed).toBe(true);
comboA.getChildren().nodes.forEach((node) => {
expect(node.isVisible()).toBe(false);
});
comboA.getChildren().combos.forEach((combo) => {
expect(combo.isVisible()).toBe(false);
});
graph.emit('combo:click', { item: comboC });
expect(comboC.getModel().collapsed).toBe(false);
comboC.getChildren().nodes.forEach((node) => {
expect(node.isVisible()).toBe(true);
});
comboC.getChildren().combos.forEach((combo) => {
expect(combo.isVisible()).toBe(true);
});
// but A is still in collapsed state
expect(comboA.getModel().collapsed).toBe(true);
comboA.getChildren().nodes.forEach((node) => {
expect(node.isVisible()).toBe(false);
});
comboA.getChildren().combos.forEach((combo) => {
expect(combo.isVisible()).toBe(false);
});
graph.destroy();
done();
}, 250);
});
it('collapse-expand-combo behavior with invalid trigger', (done) => {
data.combos.forEach((combo: ComboConfig) => {
delete combo.collapsed;
});
const graph = new G6.Graph({
container: 'container',
width: 1000,
height: 800,
modes: {
default: [
{
type: 'collapse-expand-combo',
trigger: 'mouseenter',
},
],
},
});
graph.read(data);
const comboA = graph.findById('A') as ICombo;
// collapse A and collapse B
graph.emit('combo:mouseenter', { item: comboA });
expect(comboA.getModel().collapsed).toBe(undefined);
comboA.getChildren().nodes.forEach((node) => {
expect(node.isVisible()).toBe(true);
});
comboA.getChildren().combos.forEach((combo) => {
expect(combo.isVisible()).toBe(true);
});
graph.destroy();
done();
});
it('collapse-expand-combo behavior with layout', (done) => {
data.combos.forEach((combo: ComboConfig) => {
delete combo.collapsed;
});
const graph = new G6.Graph({
container: 'container',
width: 1000,
height: 800,
modes: {
default: [
{
type: 'collapse-expand-combo',
},
],
},
layout: {
type: 'comboForce',
},
});
graph.read(data);
const comboA = graph.findById('A') as ICombo;
// collapse A and collapse B
graph.emit('combo:dblclick', { item: comboA });
expect(comboA.getModel().collapsed).toBe(true);
comboA.getChildren().nodes.forEach((node) => {
expect(node.isVisible()).toBe(false);
});
comboA.getChildren().combos.forEach((combo) => {
expect(combo.isVisible()).toBe(false);
});
graph.destroy();
done();
});
it('click on item which is not a combo', (done) => {
data.combos.forEach((combo: ComboConfig) => {
delete combo.collapsed;
});
const graph = new G6.Graph({
container: 'container',
width: 1000,
height: 800,
modes: {
default: [
{
type: 'collapse-expand-combo',
},
'zoom-canvas',
'drag-canvas',
],
},
layout: {
type: 'comboForce',
},
});
graph.read(data);
const node = graph.getNodes()[0];
const comboB = graph.findById('B') as ICombo;
graph.emit('combo:dblclick', { item: node });
expect(comboB.getModel().collapsed).toBe(undefined);
comboB.getChildren().nodes.forEach((node) => {
expect(node.isVisible()).toBe(true);
});
comboB.getChildren().combos.forEach((combo) => {
expect(combo.isVisible()).toBe(true);
});
graph.destroy();
done();
});
});

View File

@ -0,0 +1,230 @@
import '../../../src/behavior';
import '../../../src/shape';
import TreeGraph from '../../../src/graph/tree-graph';
const div = document.createElement('div');
div.id = 'collapse-expand-spec';
document.body.appendChild(div);
function approximateEqual(a, b) {
return Math.abs(a - b) < 0.0001;
}
describe('collapse expand tree graph', () => {
const data = {
isRoot: true,
id: 'Root',
style: {
fill: 'red',
},
children: [
{
id: 'SubTreeNode1',
raw: {},
children: [
{
id: 'SubTreeNode1.1',
},
{
id: 'SubTreeNode1.2',
label: 'SubTreeNode1.2',
children: [
{
id: 'SubTreeNode1.2.1',
},
{
id: 'SubTreeNode1.2.2',
},
{
id: 'SubTreeNode1.2.3',
},
],
},
],
},
{
id: 'SubTreeNode2',
children: [
{
id: 'SubTreeNode2.1',
},
],
},
{
id: 'SubTreeNode3',
label: 'SubTreeNode3',
children: [
{
id: 'SubTreeNode3.1',
},
{
id: 'SubTreeNode3.2',
},
{
id: 'SubTreeNode3.3',
},
],
},
{
id: 'SubTreeNode4',
},
{
id: 'SubTreeNode5',
},
{
id: 'SubTreeNode6',
},
{
id: 'SubTreeNode7',
label: 'SubTreeNode7',
},
{
id: 'SubTreeNode8',
},
{
id: 'SubTreeNode9',
},
{
id: 'SubTreeNode10',
},
{
id: 'SubTreeNode11',
},
],
};
const graph = new TreeGraph({
container: div,
width: 500,
height: 500,
layout: {
type: 'compactBox',
},
fitView: true,
modes: {
default: ['collapse-expand'],
},
defaultNode: {
size: 10,
},
});
graph.data(data);
graph.render();
const parent = graph.findById('SubTreeNode1.2');
it('collapse tree graph', () => {
graph.once('afterrefreshlayout', () => {
expect(parent.getModel().collapsed).toEqual(true);
expect(parent.get('children').length).toEqual(0);
});
graph.emit('node:click', { item: parent });
});
it('expand tree graph', () => {
graph.emit('node:click', { item: parent });
graph.once('afterrefreshlayout', () => {
expect(parent.getModel().collapsed).toEqual(false);
expect(parent.get('children').length).toEqual(2);
});
});
it('expand a leaf', () => {
const leaf = graph.findById('SubTreeNode7');
graph.once('afterrefreshlayout', () => {
expect(leaf.getModel().collapsed).toEqual(false);
});
graph.emit('node:click', { item: leaf });
graph.destroy();
});
it('collapse-expand with invalid trigger', () => {
const graph2 = new TreeGraph({
container: div,
width: 500,
height: 500,
layout: {
type: 'compactBox',
},
fitView: true,
modes: {
default: [
{
type: 'collapse-expand',
trigger: 'testInvalid',
},
],
},
defaultNode: {
size: 10,
},
});
graph2.data(data);
graph2.render();
const parent2 = graph2.findById('SubTreeNode1.2');
graph2.once('afterrefreshlayout', () => {
expect(parent2.getModel().collapsed).toEqual(true);
expect(parent2.get('children').length).toEqual(0);
});
graph2.emit('node:click', { item: parent2 });
graph2.destroy();
});
it('prevent begin', () => {
const graph2 = new TreeGraph({
container: div,
width: 500,
height: 500,
layout: {
type: 'compactBox',
},
fitView: true,
modes: {
default: [
{
type: 'collapse-expand',
shouldBegin: () => {
return false;
},
},
],
},
defaultNode: {
size: 10,
},
});
graph2.data(data);
graph2.render();
const parent2 = graph2.findById('SubTreeNode3');
graph2.once('afterrefreshlayout', () => {
expect(parent2.getModel().collapsed).toEqual(false);
});
graph2.emit('node:click', { item: parent2 });
graph2.destroy();
});
it('prevent update', () => {
const graph2 = new TreeGraph({
container: div,
width: 500,
height: 500,
layout: {
type: 'compactBox',
},
fitView: true,
modes: {
default: [
{
type: 'collapse-expand',
shouldUpdate: () => {
return false;
},
},
],
},
defaultNode: {
size: 10,
},
});
graph2.data(data);
graph2.render();
const parent2 = graph2.findById('SubTreeNode3');
graph2.once('afterrefreshlayout', () => {
expect(parent2.getModel().collapsed).toEqual(false);
});
graph2.emit('node:click', { item: parent2 });
graph2.destroy();
});
});

View File

@ -0,0 +1,473 @@
import '../../../src/behavior';
import '../../../src/shape';
import Graph from '../../../src/graph/graph';
import G6 from '../../../src'
const div = document.createElement('div');
div.id = 'drag-spec';
document.body.appendChild(div);
const data = {
nodes: [
{
id: '0',
x: 50,
y: 50,
},
{
id: '1',
x: 150,
y: 150,
},
],
};
describe('create-edge', () => {
it('create edge default config', () => {
const graph: Graph = new Graph({
container: div,
width: 500,
height: 500,
modes: {
default: ['create-edge'],
},
defaultEdge: {
style: {
stroke: '#f00',
lineWidth: 2
}
},
});
graph.data(data);
graph.render();
const node0 = graph.getNodes()[0];
const node1 = graph.getNodes()[1];
graph.emit('node:click', { x: 100, y: 100, item: node0 });
graph.emit('mousemove', { x: 110, y: 110 });
expect(graph.getEdges().length).toEqual(1);
const edge = graph.getEdges()[0];
// cancel
graph.emit('edge:click', { x: 100, y: 100, item: edge });
expect(graph.getEdges().length).toEqual(0);
// create
graph.emit('node:click', { x: 100, y: 100, item: node0 });
graph.emit('node:click', { x: 120, y: 120, item: node1 });
expect(graph.getEdges().length).toEqual(1);
// loop
graph.emit('node:click', { x: 100, y: 100, item: node0 });
graph.emit('node:click', { x: 100, y: 100, item: node0 });
expect(graph.getEdges().length).toEqual(2);
const loop = graph.getEdges()[1];
expect(loop.getModel().source).toEqual(loop.getModel().target);
graph.destroy();
});
it('create edge width polyline edge', () => {
const graph: Graph = new Graph({
container: div,
width: 500,
height: 500,
modes: {
default: ['create-edge'],
},
defaultEdge: {
type: 'polyline',
style: {
stroke: '#f00',
lineWidth: 2
}
},
});
graph.data(data);
graph.render();
const node0 = graph.getNodes()[0];
const node1 = graph.getNodes()[1];
graph.emit('node:click', { x: 100, y: 100, item: node0 });
graph.emit('mousemove', { x: 110, y: 110 });
expect(graph.getEdges().length).toEqual(1);
const edge = graph.getEdges()[0];
// cancel
graph.emit('edge:click', { x: 100, y: 100, item: edge });
expect(graph.getEdges().length).toEqual(0);
// create
graph.emit('node:click', { x: 100, y: 100, item: node0 });
graph.emit('node:click', { x: 120, y: 120, item: node1 });
expect(graph.getEdges().length).toEqual(1);
// loop
graph.emit('node:click', { x: 100, y: 100, item: node0 });
graph.emit('node:click', { x: 100, y: 100, item: node0 });
expect(graph.getEdges().length).toEqual(2);
const loop = graph.getEdges()[1];
expect(loop.getModel().source).toEqual(loop.getModel().target);
graph.destroy();
});
it('create edge with drag trigger', () => {
const graph: Graph = new Graph({
container: div,
width: 500,
height: 500,
modes: {
default: [{
type: 'create-edge',
trigger: 'drag'
}],
},
});
graph.data(data);
graph.render();
const node0 = graph.getNodes()[0];
const node1 = graph.getNodes()[1];
graph.emit('node:dragstart', { x: 100, y: 100, item: node0 });
graph.emit('drag', { x: 110, y: 110 });
expect(graph.getEdges().length).toEqual(1);
const edge = graph.getEdges()[0];
// cancel
graph.emit('dragend', { x: 100, y: 100, item: edge });
expect(graph.getEdges().length).toEqual(0);
// create
graph.emit('node:dragstart', { x: 100, y: 100, item: node0 });
graph.emit('node:drop', { x: 120, y: 120, item: node1 });
expect(graph.getEdges().length).toEqual(1);
graph.destroy();
});
it('create edge with click and shift key', () => {
const graph: Graph = new Graph({
container: div,
width: 500,
height: 500,
modes: {
default: [{
type: 'create-edge',
key: 'shift'
}],
},
});
graph.data(data);
graph.render();
const node0 = graph.getNodes()[0];
const node1 = graph.getNodes()[1];
// without keydown
graph.emit('node:click', { x: 100, y: 100, item: node0 });
graph.emit('mousemove', { x: 110, y: 110 });
expect(graph.getEdges().length).toEqual(0);
// keydown with wrong key
graph.emit('keydown', { key: 'a' });
graph.emit('node:click', { x: 100, y: 100, item: node0 });
graph.emit('mousemove', { x: 110, y: 110 });
expect(graph.getEdges().length).toEqual(0);
// keydown with correct key
graph.emit('keydown', { key: 'shift' });
graph.emit('node:click', { x: 100, y: 100, item: node0 });
graph.emit('mousemove', { x: 110, y: 110 });
expect(graph.getEdges().length).toEqual(1);
// cancel with keyup
graph.emit('keyup', {});
expect(graph.getEdges().length).toEqual(0);
// create
graph.emit('keydown', { key: 'shift' });
graph.emit('node:click', { x: 100, y: 100, item: node0 });
graph.emit('node:click', { x: 100, y: 100, item: node1 });
expect(graph.getEdges().length).toEqual(1);
graph.destroy();
});
it('shouldBegin, shouldEnd', () => {
const graph: Graph = new Graph({
container: div,
width: 500,
height: 500,
modes: {
default: [{
type: 'create-edge',
shouldBegin: e => {
if (e.target && e.target.get('name') === 'text-shape') {
return false;
}
return true;
},
shouldEnd: e => {
if (e.target && e.target.get('name') === 'text-shape') {
return false;
}
return true;
}
}],
},
defaultEdge: {
style: {
stroke: '#f00',
lineWidth: 2
}
},
});
graph.node(node => {
return {
label: node.id,
labelCfg: {
position: 'bottom'
}
}
})
graph.data(data);
graph.render();
const node0 = graph.getNodes()[0];
const node0text = node0.getContainer().find(e => e.get('name') === 'text-shape')
const node1 = graph.getNodes()[1];
const node1text = node0.getContainer().find(e => e.get('name') === 'text-shape')
// shouldBegin returns true
graph.emit('node:click', { x: 100, y: 100, item: node0, target: node0.getKeyShape() });
expect(graph.getEdges().length).toEqual(1);
// cancel
let edge = graph.getEdges()[0];
graph.emit('edge:click', { x: 100, y: 100, item: edge });
expect(graph.getEdges().length).toEqual(0);
// shouldBegin returns false
graph.emit('node:click', { x: 100, y: 100, item: node0, target: node0text });
expect(graph.getEdges().length).toEqual(0);
// cancel
edge = graph.getEdges()[0];
graph.emit('edge:click', { x: 100, y: 100, item: edge });
expect(graph.getEdges().length).toEqual(0);
// shouldEnd returns false
graph.emit('node:click', { x: 100, y: 100, item: node0 });
graph.emit('node:click', { x: 100, y: 100, item: node1, target: node1text });
expect(graph.getEdges().length).toEqual(1);
edge = graph.getEdges()[0];
expect(edge.getModel().target).not.toBe(node1.getID());
graph.destroy();
});
it('create edge width stack', () => {
const toolbar = new G6.ToolBar()
const graph: Graph = new Graph({
container: div,
width: 500,
height: 500,
enabledStack: true,
plugins: [toolbar],
modes: {
default: ['create-edge'],
},
defaultEdge: {
style: {
stroke: '#f00',
lineWidth: 2
}
},
});
graph.data(data);
graph.render();
const node0 = graph.getNodes()[0];
const node1 = graph.getNodes()[1];
graph.emit('node:click', { x: 100, y: 100, item: node0 });
graph.emit('mousemove', { x: 110, y: 110 });
expect(graph.getEdges().length).toEqual(1);
const edge = graph.getEdges()[0];
let stackData = graph.getStackData()
const { undoStack, redoStack } = stackData
expect(undoStack.length).toBe(1)
expect(redoStack.length).toBe(0)
// cancel
graph.emit('edge:click', { x: 100, y: 100, item: edge });
expect(graph.getEdges().length).toEqual(0);
// create
graph.emit('node:click', { x: 100, y: 100, item: node0 });
graph.emit('node:click', { x: 120, y: 120, item: node1 });
expect(graph.getEdges().length).toEqual(1);
stackData = graph.getStackData()
expect(stackData.undoStack.length).toBe(2)
expect(stackData.redoStack.length).toBe(0)
// loop
graph.emit('node:click', { x: 100, y: 100, item: node0 });
graph.emit('node:click', { x: 100, y: 100, item: node0 });
stackData = graph.getStackData()
expect(stackData.undoStack.length).toBe(3)
expect(stackData.redoStack.length).toBe(0)
expect(graph.getEdges().length).toEqual(2);
const loop = graph.getEdges()[1];
expect(loop.getModel().source).toEqual(loop.getModel().target);
graph.destroy();
});
it('create edge width edge configures', () => {
const graph: Graph = new Graph({
container: div,
width: 500,
height: 500,
enabledStack: true,
modes: {
default: [{
type: 'create-edge',
edgeConfig: {
type: 'cubic',
style: {
stroke: '#f00'
}
}
}],
},
defaultEdge: {
style: {
stroke: '#0f0',
lineWidth: 5
}
},
});
graph.data(data);
graph.render();
const node0 = graph.getNodes()[0];
const node1 = graph.getNodes()[1];
graph.addItem('edge', {
source: '0',
target: '1'
})
graph.emit('node:click', { x: 100, y: 100, item: node0 });
graph.emit('mousemove', { x: 110, y: 110 });
expect(graph.getEdges().length).toEqual(2);
let edge = graph.getEdges()[1];
expect(edge.getModel().type).toEqual('cubic');
let keyShape = edge.getKeyShape();
expect(keyShape.attr('stroke')).toEqual('#f00');
expect(keyShape.attr('lineWidth')).toEqual(1);
// cancel
graph.emit('edge:click', { x: 100, y: 100, item: edge });
expect(graph.getEdges().length).toEqual(1);
// create
graph.emit('node:click', { x: 100, y: 100, item: node0 });
graph.emit('node:click', { x: 120, y: 120, item: node1 });
expect(graph.getEdges().length).toEqual(2);
edge = graph.getEdges()[1];
keyShape = edge.getKeyShape();
expect(keyShape.attr('stroke')).toEqual('#f00');
expect(keyShape.attr('lineWidth')).toEqual(1);
graph.destroy();
});
it('click first node and remove the source node', () => {
const graph: Graph = new Graph({
container: div,
width: 500,
height: 500,
modes: {
default: ['create-edge'],
}
});
graph.data(data);
graph.render();
graph.on('node:click', e => {
graph.removeItem('0');
});
expect(graph.getEdges().length).toEqual(0);
graph.destroy();
});
it('create edge for combos ', () => {
const dataWithCombos = {
nodes: [
{
id: '0',
x: 50,
y: 50,
comboId: 'combo0'
},
{
id: '1',
x: 150,
y: 150,
comboId: 'combo1'
},
],
combos: [
{
id: "combo0"
},
{
id: "combo1"
}
]
}
const graph: Graph = new Graph({
container: div,
width: 500,
height: 500,
modes: {
default: ['drag-node', 'drag-combo', {
type: 'create-edge',
trigger: 'click'
}],
},
groupByTypes: false
});
graph.data(dataWithCombos);
graph.render();
const node0 = graph.getNodes()[0];
const node1 = graph.getNodes()[1];
const combo0 = graph.getCombos()[0];
const combo1 = graph.getCombos()[1];
// start from a combo, end on a node
graph.emit('node:click', { x: 100, y: 100, item: combo0 });
graph.emit('mousemove', { x: 110, y: 110 });
expect(graph.getEdges().length).toEqual(1);
// cancel
graph.emit('canvas:click', { x: 100, y: 100, target: graph.get('canvas') });
expect(graph.getEdges().length).toEqual(0);
// create
graph.emit('node:click', { x: 100, y: 100, item: combo0 });
graph.emit('node:click', { x: 120, y: 120, item: node1 });
expect(graph.getEdges().length).toEqual(1);
// start from a node, end on a combo
graph.emit('node:click', { x: 100, y: 100, item: node0 });
graph.emit('mousemove', { x: 110, y: 110 });
expect(graph.getEdges().length).toEqual(2);
// cancel
graph.emit('canvas:click', { x: 100, y: 100, target: graph.get('canvas') });
expect(graph.getEdges().length).toEqual(1);
// create
graph.emit('node:click', { x: 100, y: 100, item: node0 });
graph.emit('node:click', { x: 120, y: 120, item: combo0 });
expect(graph.getEdges().length).toEqual(2);
// start from a combo, end on a combo
graph.emit('node:click', { x: 100, y: 100, item: combo0 });
graph.emit('mousemove', { x: 110, y: 110 });
expect(graph.getEdges().length).toEqual(3);
// cancel
graph.emit('canvas:click', { x: 100, y: 100, target: graph.get('canvas') });
expect(graph.getEdges().length).toEqual(2);
// create
graph.emit('node:click', { x: 100, y: 100, item: combo0 });
graph.emit('node:click', { x: 120, y: 120, item: combo1 });
expect(graph.getEdges().length).toEqual(3);
graph.destroy();
});
});

View File

@ -0,0 +1,493 @@
import '../../../src/behavior';
import '../../../src/shape';
import Graph from '../../../src/graph/graph';
const div = document.createElement('div');
div.id = 'pan-spec';
document.body.appendChild(div);
const data = {
nodes: [
{
id: 'node1',
x: 50,
y: 50,
},
{
id: 'node2',
x: 200,
y: 50,
},
],
edges: [
{
source: 'node1',
target: 'node2',
},
],
};
describe('drag-canvas', () => {
it('drag canvas', () => {
const graph = new Graph({
container: div,
width: 500,
height: 500,
modes: {
default: ['drag-canvas'],
},
});
graph.data(data);
graph.render();
let start = false;
graph.on('canvas:dragstart', () => {
start = true;
});
graph.on('canvas:dragend', () => {
start = false;
});
graph.paint();
graph.emit('dragstart', { clientX: 150, clientY: 150, target: graph.get('canvas') });
graph.emit('drag', { clientX: 200, clientY: 200, target: graph.get('canvas') });
expect(start).toBe(true);
graph.emit('drag', { clientX: 250, clientY: 250, target: graph.get('canvas') });
expect(start).toBe(true);
const matrix = graph.get('group').getMatrix();
expect(matrix[6]).toEqual(100);
expect(matrix[7]).toEqual(100);
graph.emit('dragend', {});
expect(start).toBe(false);
graph.destroy();
});
it('prevent default drag behavior', () => {
const graph = new Graph({
container: div,
width: 500,
height: 500,
modes: {
default: [
{
type: 'drag-canvas',
shouldUpdate: () => {
return false;
},
},
],
},
});
graph.data(data);
graph.render();
let start = false;
graph.on('canvas:dragstart', () => {
start = true;
});
graph.on('canvas:dragend', () => {
start = false;
});
graph.paint();
graph.emit('dragstart', { clientX: 150, clientY: 150, target: graph.get('canvas') });
graph.emit('drag', { clientX: 200, clientY: 200, target: graph.get('canvas') });
expect(start).toBe(true);
graph.emit('drag', { clientX: 250, clientY: 250, target: graph.get('canvas') });
expect(start).toBe(true);
const matrix = graph.get('group').getMatrix();
expect(matrix).toEqual(null);
graph.emit('canvas:dragend', {});
expect(start).toBe(false);
graph.destroy();
});
it('unbind event', () => {
const graph = new Graph({
container: div,
width: 500,
height: 500,
modes: {
default: [
{
type: 'drag-canvas',
},
],
custom: [],
},
});
graph.data(data);
graph.render();
let triggered = false;
graph.setMode('custom');
graph.on('canvas:dragstart', () => {
triggered = true;
});
graph.on('canvas:dragend', () => {
triggered = true;
});
graph.emit('dragstart', { clientX: 150, clientY: 150, target: graph.get('canvas') });
graph.emit('drag', { clientX: 200, clientY: 200, target: graph.get('canvas') });
expect(triggered).toBe(false);
graph.emit('drag', { clientX: 250, clientY: 250, target: graph.get('canvas') });
expect(triggered).toBe(false);
graph.emit('dragend', { clientX: 250, clientY: 250 });
expect(triggered).toBe(false);
graph.destroy();
});
it('drag with x direction restrict', () => {
const graph = new Graph({
container: div,
width: 500,
height: 500,
modes: {
default: [
{
type: 'drag-canvas',
direction: 'x',
},
],
},
});
graph.data(data);
graph.render();
let start = false;
graph.addItem('node', { x: 100, y: 100, color: '#666', type: 'rect', id: 'test' });
graph.on('canvas:dragstart', () => {
start = true;
});
graph.on('canvas:dragend', () => {
start = false;
});
graph.paint();
graph.emit('dragstart', { clientX: 150, clientY: 150, target: graph.get('canvas') });
graph.emit('drag', { clientX: 200, clientY: 200, target: graph.get('canvas') });
expect(start).toBe(true);
graph.emit('drag', { clientX: 250, clientY: 250, target: graph.get('canvas') });
expect(start).toBe(true);
const matrix = graph.get('group').getMatrix();
expect(matrix[6]).toEqual(100);
expect(matrix[7]).toEqual(0);
graph.emit('dragend', { clientX: 200, clientY: 200 });
expect(start).toBe(false);
graph.destroy();
});
it('drag with y direction restrict', () => {
const graph = new Graph({
container: div,
width: 500,
height: 500,
modes: {
default: [
{
type: 'drag-canvas',
direction: 'y',
},
],
},
});
let start = false;
graph.addItem('node', { x: 100, y: 100, color: '#666', type: 'rect', id: 'test' });
graph.paint();
graph.emit('dragstart', { clientX: 150, clientY: 150, target: graph.get('canvas') });
graph.emit('drag', { clientX: 250, clientY: 250, target: graph.get('canvas') });
const matrix = graph.get('group').getMatrix();
expect(matrix[6]).toEqual(0);
expect(matrix[7]).toEqual(100);
graph.emit('dragend', { clientX: 250, clientY: 250 });
graph.destroy();
});
it('drag offset', () => {
const graph = new Graph({
container: div,
width: 500,
height: 500,
modes: {
default: [
{
type: 'drag-canvas',
},
],
},
});
let triggered = false;
let dragging = false;
graph.on('canvas:dragstart', () => {
triggered = true;
});
graph.on('canvas:drag', () => {
dragging = true;
});
graph.emit('dragstart', { clientX: 150, clientY: 150, target: graph.get('canvas') });
graph.emit('drag', { clientX: 150, clientY: 150, target: graph.get('canvas') });
expect(triggered).toBe(false);
expect(dragging).toBe(false);
graph.emit('drag', { clientX: 160, clientY: 160, target: graph.get('canvas') });
expect(triggered).toBe(true);
graph.emit('drag', { clientX: 170, clientY: 180, target: graph.get('canvas') });
expect(dragging).toBe(true);
dragging = false;
graph.emit('canvas:click', { clientX: 170, clientY: 170, target: graph.get('canvas') });
graph.emit('drag', { clientX: 170, clientY: 170, target: graph.get('canvas') });
expect(dragging).toBe(false);
graph.destroy();
});
it('drag with keydown', () => {
const graph = new Graph({
container: div,
width: 500,
height: 500,
modes: {
default: [
{
type: 'drag-canvas',
},
],
},
});
let triggered = false;
let dragging = false;
graph.on('canvas:dragstart', () => {
triggered = true;
});
graph.on('canvas:drag', () => {
dragging = true;
});
// mouse down and up without moving
graph.emit('dragstart', { clientX: 150, clientY: 150, target: graph.get('canvas') });
graph.emit('dragend', { clientX: 150, clientY: 150, target: graph.get('canvas') });
expect(triggered).toBe(false);
expect(dragging).toBe(false);
graph.emit('keydown', { key: 'shift' });
graph.emit('dragstart', { clientX: 150, clientY: 150, target: graph.get('canvas') });
expect(triggered).toBe(false);
expect(dragging).toBe(false);
graph.emit('drag', { clientX: 160, clientY: 160, target: graph.get('canvas') });
expect(triggered).toBe(false);
graph.emit('dragend', { clientX: 160, clientY: 160 });
expect(triggered).toBe(false);
graph.emit('keyup', { key: 'shift' });
graph.emit('dragstart', { clientX: 150, clientY: 150, target: graph.get('canvas') });
graph.emit('drag', { clientX: 150, clientY: 150, target: graph.get('canvas') });
expect(triggered).toBe(false);
expect(dragging).toBe(false);
graph.emit('drag', { clientX: 160, clientY: 160, target: graph.get('canvas') });
expect(triggered).toBe(true);
graph.destroy();
});
it('drag with keydown code invalid', () => {
const graph = new Graph({
container: div,
width: 500,
height: 500,
modes: {
default: [
{
type: 'drag-canvas',
},
],
},
});
let triggered = false;
graph.on('canvas:dragstart', () => {
triggered = true;
});
graph.emit('keydown', {}); // key undefined
graph.emit('dragstart', { clientX: 150, clientY: 150, target: graph.get('canvas') });
graph.emit('dragend', { clientX: 160, clientY: 160 });
expect(triggered).toBe(false);
graph.emit('keyup', {});
graph.emit('keydown', { key: 'abc' }); // key invalid
graph.emit('dragstart', { clientX: 150, clientY: 150, target: graph.get('canvas') });
graph.emit('dragend', { clientX: 160, clientY: 160 });
expect(triggered).toBe(false);
graph.emit('keyup', {});
graph.destroy();
});
it('drag out of canvas', () => {
const graph = new Graph({
container: div,
width: 500,
height: 500,
modes: {
default: [
{
type: 'drag-canvas',
},
],
},
});
graph.emit('dragstart', { clientX: 150, clientY: 150, target: graph.get('canvas') });
graph.emit('drag', { clientX: 550, clientY: 550, target: graph.get('canvas') });
graph.emit('canvas:mouseleave', { target: graph.get('canvas').get('el') });
// dragend out of the canvas
const event = document.createEvent('MouseEvents');
event.initMouseEvent(
'dragend',
true,
true,
document.defaultView,
0,
0,
0,
550,
550, // clientX = 550, clientY = 550
false,
false,
false,
false,
0,
null,
);
document.body.dispatchEvent(event);
const movedMatrix = graph.get('canvas').get('children')[0].getMatrix();
expect(movedMatrix[6]).toEqual(400);
expect(movedMatrix[7]).toEqual(400);
});
it('drag out of canvas, but it is not dragging', () => {
const graph = new Graph({
container: div,
width: 500,
height: 500,
modes: {
default: [
{
type: 'drag-canvas',
},
],
},
});
graph.emit('dragstart', { clientX: 150, clientY: 150, target: graph.get('canvas') });
graph.emit('drag', { clientX: 350, clientY: 350, target: graph.get('canvas') });
graph.emit('dragend', { clientX: 350, clientY: 350 });
graph.emit('canvas:mouseleave', { target: graph.get('canvas').get('el') });
// dragend out of the canvas
const event = document.createEvent('MouseEvents');
event.initMouseEvent(
'dragend',
true,
true,
document.defaultView,
0,
0,
0,
550,
550, // clientX = 550, clientY = 550
false,
false,
false,
false,
0,
null,
);
document.body.dispatchEvent(event);
const movedMatrix = graph.get('canvas').get('children')[0].getMatrix();
expect(movedMatrix[6]).toEqual(200);
expect(movedMatrix[7]).toEqual(200);
});
it('enable optimize to hide the shapes while dragging', () => {
const graph = new Graph({
container: div,
width: 500,
height: 500,
modes: {
default: [
{
type: 'drag-canvas',
enableOptimize: true
},
],
},
});
graph.data(data);
graph.render();
graph.emit('dragstart', { clientX: 150, clientY: 150, target: graph.get('canvas') });
graph.emit('drag', { clientX: 350, clientY: 350, target: graph.get('canvas') });
let nodes = graph.getNodes();
nodes.forEach(node => {
const children = node.getContainer().get('children');
children.forEach(child => {
if (!child.get('isKeyShape')) expect(child.get('visible')).toEqual(false);
else expect(child.get('visible')).toEqual(true);
});
})
let edges = graph.getEdges();
edges.forEach(edge => {
const children = edge.getContainer().get('children');
children.forEach(child => {
expect(child.get('visible')).toEqual(false);
});
})
graph.emit('dragend', { clientX: 350, clientY: 350 });
nodes = graph.getNodes();
nodes.forEach(node => {
const children = node.getContainer().get('children');
children.forEach(child => {
if (!child.get('isKeyShape')) expect(child.get('visible')).toEqual(true);
else expect(child.get('visible')).toEqual(true);
});
})
edges = graph.getEdges();
edges.forEach(edge => {
const children = edge.getContainer().get('children');
children.forEach(child => {
expect(child.get('visible')).toEqual(true);
});
})
});
it('drag canvas with scalableRange', () => {
const graph = new Graph({
container: div,
width: 500,
height: 500,
modes: {
default: [{
type: 'drag-canvas',
scalableRange: -150
}],
},
});
graph.data(data);
graph.render();
let start = false;
graph.on('canvas:dragstart', () => {
start = true;
});
graph.on('canvas:dragend', () => {
start = false;
});
graph.paint();
graph.emit('dragstart', { clientX: 150, clientY: 150, target: graph.get('canvas') });
graph.emit('drag', { clientX: 200, clientY: 200, target: graph.get('canvas') });
expect(start).toBe(true);
graph.emit('drag', { clientX: 250, clientY: 250, target: graph.get('canvas') });
expect(start).toBe(true);
let matrix = graph.get('group').getMatrix();
expect(matrix[6]).toEqual(100);
expect(matrix[7]).toEqual(100);
graph.emit('drag', { clientX: 400, clientY: 350, target: graph.get('canvas') });
matrix = graph.get('group').getMatrix();
expect(matrix[6]).toEqual(250);
expect(matrix[7]).toEqual(200);
expect(start).toBe(true);
// 超过了设置的范围,则不再移动
graph.emit('drag', { clientX: 550, clientY: 550, target: graph.get('canvas') });
matrix = graph.get('group').getMatrix();
expect(matrix[6]).toEqual(250);
expect(matrix[7]).toEqual(200);
expect(start).toBe(true);
graph.emit('dragend', {});
expect(start).toBe(false);
graph.destroy();
});
});

View File

@ -0,0 +1,390 @@
import '../../../src/behavior';
import '../../../src/shape';
import Graph from '../../../src/graph/graph';
import G6 from '../../../src';
const div = document.createElement('div');
div.id = 'drag-combo-spec';
document.body.appendChild(div);
G6.registerCombo(
'custom-combo',
{
draw: (cfg, group) => {
const style = cfg.style || {};
const keyShape = group.addShape('circle', {
attrs: style,
className: 'circle-combo',
name: 'circle-combo',
draggable: true,
});
group.addShape('marker', {
attrs: {
x: 0, //keyShape.attr('r') + 5,
y: 0,
r: 5,
stroke: 'blue',
symbol: 'triangle-down',
},
name: 'marker-shape',
});
return keyShape;
},
update: (cfg, item) => {
const group = item.get('group');
if (cfg.markerStyle) {
const marker = group.find((ele) => ele.get('name') === 'marker-shape');
marker.attr(cfg.markerStyle);
}
const keyShape = group.get('children')[0];
keyShape.attr(cfg.style);
},
},
'circle-combo',
);
describe('drag-combo', () => {
it('drag combo', () => {
const data = {
nodes: [
{
id: 'node1',
x: 150,
y: 150,
label: 'node1',
comboId: 'A',
},
{
id: 'node2',
x: 200,
y: 250,
label: 'node2',
comboId: 'A',
},
{
id: 'node3',
x: 100,
y: 250,
label: 'node3',
},
{
id: 'node4',
x: 200,
y: 350,
label: 'node4',
comboId: 'B',
},
],
edges: [
{
source: 'node1',
target: 'node4',
},
{
source: 'node1',
target: 'node2',
},
{
source: 'node2',
target: 'node3',
},
],
combos: [
{
id: 'A',
parentId: 'C',
label: 'gorup A',
type: 'circle',
},
{
id: 'B',
parentId: 'C',
label: 'gorup B',
},
{
id: 'C',
label: 'gorup C',
// type: 'rect'
},
{
id: 'F',
label: 'gorup F',
// type: 'rect'
},
{
id: 'G',
label: 'gorup G',
// parentId: 'F'
type: 'custom-combo',
},
],
};
const graph = new G6.Graph({
container: 'drag-combo-spec',
width: 1000,
height: 800,
modes: {
default: ['drag-canvas', 'drag-combo'],
},
defaultCombo: {
// size: [100, 100],
// type: 'custom-combo',
style: {
fill: '#b5f5ec',
},
},
comboStateStyles: {
active: {
stroke: 'red',
},
selected: {
'text-shape': {
fill: '#f00',
fontSize: 20,
},
fill: '#36cfc9',
},
state2: {
stroke: '#0f0',
},
},
});
graph.data(data);
graph.render();
graph.on('node:click', (e) => {
// graph.hideItem(e.item);
// graph.render()
});
graph.on('combo:click', (e) => {
// selected = !selected;
graph.setItemState(e.item, 'selected', true);
const combos = graph.findAllByState('combo', 'selected');
// graph.setItemState(e.item, 'state2', selected);
// graph.getNodes().forEach(node => {
// node.hide();
// });
// graph.hideItem(e.item);
// graph.updateItem(e.item, {
// // type: 'rect',
// style: {
// fill: '#f00'
// },
// label: 'new Label',
// labelCfg: {
// position: 'bottom'
// }
// });
// graph.uncombo(e.item);
});
graph.on('canvas:click', (evt) => {
const combos = graph.findAllByState('combo', 'selected');
combos.map((combo) => {
graph.clearItemStates(combo);
});
});
});
it('combo example', () => {
const data = {
nodes: [
{
id: 'node1',
x: 150,
y: 150,
label: 'node1',
comboId: 'A',
},
{
id: 'node2',
x: 200,
y: 250,
label: 'node2',
comboId: 'B',
},
{
id: 'node3',
x: 100,
y: 250,
label: 'node3',
},
{
id: 'node4',
x: 50,
y: 50,
label: 'node4',
comboId: 'D',
},
{
id: 'node5',
x: 100,
y: 100,
label: 'node5',
comboId: 'E',
},
{
id: 'node6',
x: 500,
y: 200,
label: 'node6',
},
{
id: 'node7',
x: 600,
y: 200,
label: 'node7',
},
],
edges: [
{
source: 'node1',
target: 'node2',
},
{
source: 'node2',
target: 'node3',
},
{
source: 'node3',
target: 'node1',
},
{
source: 'A',
target: 'D',
},
],
combos: [
{
id: 'A',
parentId: 'B',
label: 'gorup A',
padding: [50, 30, 10, 10],
type: 'rect',
// style: {
// stroke: 'red',
// fill: 'green'
// },
// collapsed: true
},
{
id: 'B',
label: 'gorup B',
padding: [50, 10, 10, 50],
// type: 'custom-combo'
},
{
id: 'D',
label: 'gorup D',
parentId: 'E',
},
{
id: 'E',
// collapsed: true
},
{
id: 'FF',
label: '空分组',
// type: 'custom-combo',
// style: {
// stroke: 'green',
// lineWidth: 3
// }
},
],
};
const graph = new G6.Graph({
container: 'drag-combo-spec',
width: 1000,
height: 800,
groupByTypes: false,
modes: {
default: [
'drag-canvas',
{
type: 'drag-combo',
activeState: 'active',
},
{
type: 'drag-node',
comboActiveState: 'active',
},
'collapse-expand-combo',
'click-select',
],
},
defaultCombo: {
// size: [100, 100],
type: 'circle',
style: {
fill: '#f0f0f0',
stroke: '#bfbfbf',
},
},
comboStateStyles: {
selected: {
// 'text-shape': {
// fill: '#f00',
// fontSize: 20
// },
stroke: '#8c8c8c',
},
active: {
stroke: '#722ed1',
lineWidth: 2,
},
state2: {
stroke: '#0f0',
},
},
nodeStateStyles: {
selected: {
stroke: 'green',
lineWidth: 2,
},
},
defaultEdge: {
style: {
stroke: '#bfbfbf',
lineWidth: 2,
endArrow: true,
},
},
});
graph.data(data);
graph.render();
graph.on('nodeselectchange', (evt) => {
// console.log(evt);
});
// 删错 combo
// graph.on('combo:click', evt => {
// const { item } = evt
// graph.removeItem(item)
// })
// graph.on('combo:click', evt => {
// const { item } = evt
// graph.uncombo(item)
// })
// setTimeout(() => {
// graph.updateComboTree('D')
// }, 1000)
// graph.on('combo:mouseenter', evt => {
// const { item } = evt
// graph.setItemState(item, 'hover', true)
// })
// graph.on('combo:mouseleave', evt => {
// const { item } = evt
// graph.setItemState(item, 'hover', false)
// })
});
});

Some files were not shown because too many files have changed in this diff Show More