feat: collapse expand combo to add virtual edges to represent the edges between collapsed combos.

This commit is contained in:
Yanyan-Wang 2020-04-24 15:28:03 +08:00 committed by Yanyan Wang
parent 4a0752de14
commit a67c1c4b56
23 changed files with 749 additions and 135 deletions

View File

@ -40,6 +40,7 @@ export default {
return;
}
graph.collapseExpandCombo(comboId);
graph.layout();
if (graph.get('layoutCfg')) graph.layout();
else graph.refreshPositions();
},
};

View File

@ -64,6 +64,7 @@ export default {
},
size: [20, 5],
color: '#A3B1BF',
padding: [25, 20, 15, 20]
},
// 节点应用状态后的样式,默认仅提供 active 和 selected 用户可以自己扩展
nodeStateStyle: {},

View File

@ -17,6 +17,7 @@ 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';
@ -46,11 +47,12 @@ export default class ItemController {
public addItem<T extends Item>(type: ITEM_TYPE, model: ModelConfig) {
const { graph } = this;
const parent: Group = graph.get(`${type}Group`) || graph.get('group');
const upperType = upperFirst(type);
const vType = type === VEDGE ? EDGE : type;
const upperType = upperFirst(vType);
let item: Item | null = null;
// 获取 this.get('styles') 中的值
let styles = graph.get(type + upperFirst(STATE_SUFFIX)) || {};
let styles = graph.get(vType + upperFirst(STATE_SUFFIX)) || {};
const defaultModel = graph.get(CFG_PREFIX + upperType);
if (model[STATE_SUFFIX]) {
@ -58,7 +60,7 @@ export default class ItemController {
styles = model[STATE_SUFFIX];
}
const mapper = graph.get(type + MAPPER_SUFFIX);
const mapper = graph.get(vType + MAPPER_SUFFIX);
if (mapper) {
const mappedModel = mapper(model);
if (mappedModel[STATE_SUFFIX]) {
@ -88,7 +90,7 @@ export default class ItemController {
graph.emit('beforeadditem', { type, model });
if (type === EDGE) {
if (type === EDGE || type === VEDGE) {
let source: Id;
let target: Id;
source = (model as EdgeConfig).source; // eslint-disable-line prefer-destructuring
@ -96,9 +98,11 @@ export default class ItemController {
if (source && isString(source)) {
source = graph.findById(source);
if (source.getType() === 'combo') model.isComboEdge = true;
}
if (target && isString(target)) {
target = graph.findById(target);
if (target.getType() === 'combo') model.isComboEdge = true;
}
if (!source || !target) {
@ -123,6 +127,7 @@ export default class ItemController {
}
else if (type === COMBO) {
const children: ComboTree[] = (model as ComboConfig).children;
const comboBBox = getComboBBox(children, graph);
model.x = comboBBox.x || Math.random() * 100;
model.y = comboBBox.y || Math.random() * 100;
@ -252,7 +257,6 @@ export default class ItemController {
if (!combo || combo.destroyed) {
return;
}
const comboBBox = getComboBBox(children, graph);
combo.set('bbox', comboBBox);
@ -260,6 +264,7 @@ export default class ItemController {
x: comboBBox.x,
y: comboBBox.y
});
}
/**
@ -322,9 +327,14 @@ export default class ItemController {
graph.emit('beforeremoveitem', { item });
const type = item.getType();
const items = graph.get(`${item.getType()}s`);
const items = graph.get(`${type}s`);
const index = items.indexOf(item);
items.splice(index, 1);
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');

View File

@ -366,12 +366,12 @@ export default class LayoutController {
nodes.push(model);
});
edgeItems.forEach(edgeItem => {
if (!edgeItem.isVisible()) return;
if (edgeItem.destroyed || !edgeItem.isVisible()) return;
const model = edgeItem.getModel();
edges.push(model);
if (!model.isComboEdge) edges.push(model);
});
comboItems.forEach(comboItem => {
if (!comboItem.isVisible()) return;
if (comboItem.destroyed || !comboItem.isVisible()) return;
const model = comboItem.getModel();
combos.push(model);
});

View File

@ -70,6 +70,8 @@ export interface PrivateGraphOption extends GraphOptions {
edges: EdgeConfig[];
vedges: EdgeConfig[];
groups: GroupConfig[];
combos: ComboConfig[];
@ -299,6 +301,10 @@ export default class Graph extends EventEmitter implements IGraph {
* store all the combo instances
*/
combos: [],
/**
* store all the edge instances which are virtual edges related to collapsed combo
*/
vedges: [],
/**
* all the instances indexed by id
*/
@ -844,8 +850,8 @@ export default class Graph extends EventEmitter implements IGraph {
found = true;
const newCombo: ComboTree = {
id: model.id as string,
depth: child.depth + 1,
...model as ComboTree
depth: child.depth + 2,
...model
}
if (child.children) child.children.push(newCombo);
else child.children = [newCombo];
@ -891,7 +897,7 @@ export default class Graph extends EventEmitter implements IGraph {
const combos = this.get('combos');
if (combos && combos.length > 0) {
this.sortCombos(this.save() as GraphData);
this.sortCombos();
}
this.autoPaint();
return item;
@ -972,9 +978,6 @@ export default class Graph extends EventEmitter implements IGraph {
self.add('node', node);
});
each(edges, (edge: EdgeConfig) => {
self.add('edge', edge);
});
// process the data to tree structure
if (combos && combos.length !== 0) {
@ -984,6 +987,10 @@ export default class Graph extends EventEmitter implements IGraph {
self.addCombos(combos);
}
each(edges, (edge: EdgeConfig) => {
self.add('edge', edge);
});
// layout
const layoutController = self.get('layoutController');
if (!layoutController.layout(success)) {
@ -999,7 +1006,7 @@ export default class Graph extends EventEmitter implements IGraph {
if (!this.get('groupByTypes')) {
if (combos && combos.length !== 0) {
this.sortCombos(data);
this.sortCombos();
} else {
// 为提升性能,选择数量少的进行操作
if (data.nodes && data.edges && data.nodes.length < data.edges.length) {
@ -1122,6 +1129,15 @@ export default class Graph extends EventEmitter implements IGraph {
}
});
// clear the destroyed combos here to avoid removing sub nodes before removing the parent combo
const comboItems = this.getCombos();
const combosLength = comboItems.length;
for (let i = combosLength - 1; i >= 0; i--) {
if (comboItems[i].destroyed) {
comboItems.splice(i, 1);
}
}
// process the data to tree structure
const combosData = (data as GraphData).combos;
if (combosData) {
@ -1129,9 +1145,9 @@ export default class Graph extends EventEmitter implements IGraph {
this.set('comboTrees', comboTrees);
// add combos
self.addCombos(combosData);
if (!this.get('groupByTypes')) this.sortCombos(data as GraphData);
}
if (!this.get('groupByTypes')) this.sortCombos();
}
this.set({ nodes: items.nodes, edges: items.edges });
@ -1231,7 +1247,7 @@ export default class Graph extends EventEmitter implements IGraph {
return true;
});
});
self.sortCombos(self.get('data'));
self.sortCombos();
}
/**
@ -1376,6 +1392,7 @@ export default class Graph extends EventEmitter implements IGraph {
} else {
const nodes: INode[] = self.get('nodes');
const edges: IEdge[] = self.get('edges');
const vedges: IEdge[] = self.get('edges');
each(nodes, (node: INode) => {
node.refresh();
@ -1384,6 +1401,10 @@ export default class Graph extends EventEmitter implements IGraph {
each(edges, (edge: IEdge) => {
edge.refresh();
});
each(vedges, (vedge: IEdge) => {
vedge.refresh();
});
}
@ -1534,6 +1555,7 @@ export default class Graph extends EventEmitter implements IGraph {
const nodes: INode[] = self.get('nodes');
const edges: IEdge[] = self.get('edges');
const vedges: IEdge[] = self.get('vedges');
const combos: ICombo[] = self.get('combos')
let model: NodeConfig;
@ -1549,17 +1571,21 @@ export default class Graph extends EventEmitter implements IGraph {
updatedNodes[model.id] = true;
});
if (combos && combos.length !== 0) {
self.updateCombos();
}
each(edges, (edge: IEdge) => {
const sourceModel = edge.getSource().getModel();
const targetModel = edge.getTarget().getModel();
if (updatedNodes[sourceModel.id as string] || updatedNodes[targetModel.id as string]) {
if (updatedNodes[sourceModel.id as string] || updatedNodes[targetModel.id as string] || edge.getModel().isComboEdge) {
edge.refresh();
}
});
if (combos && combos.length !== 0) {
self.updateCombos();
}
each(vedges, (vedge: IEdge) => {
vedge.refresh();
});
self.emit('aftergraphrefreshposition');
self.autoPaint();
@ -1852,6 +1878,7 @@ export default class Graph extends EventEmitter implements IGraph {
public layout(): void {
const layoutController = this.get('layoutController');
const layoutCfg = this.get('layout');
if (!layoutCfg) return;
if (layoutCfg.workerEnabled) {
// 如果使用web worker布局
@ -1874,6 +1901,83 @@ export default class Graph extends EventEmitter implements IGraph {
combo = this.findById(combo) as ICombo;
}
const comboModel = combo.getModel();
// add virtual edges
const edges = this.getEdges().concat(this.get('vedges'));
const cnodes = combo.getNodes();
const ccombos = combo.getCombos();
const processedNodes = {};
const addedVEdges = [];
edges.forEach(edge => {
const source = edge.getSource();
const target = edge.getTarget();
if (((cnodes.includes(source) || ccombos.includes(source))
&& (!cnodes.includes(target) && !ccombos.includes(target)))
|| (source.getModel().id === comboModel.id)) {
const edgeModel = edge.getModel();
if (edgeModel.isVEdge) {
this.removeItem(edge);
return;
}
let targetModel = target.getModel();
if (!target.isVisible()) {
targetModel = this.findById((targetModel.parentId as string) || (targetModel.comboId as string)).getModel();
}
const targetId = targetModel.id;
if (processedNodes[targetId]) {
processedNodes[targetId] += (edgeModel.size || 1);
return;
}
// the source is in the combo, the target is not
const vedge = this.addItem('vedge', {
source: comboModel.id,
target: targetId,
isVEdge: true,
});
processedNodes[targetId] = edgeModel.size || 1;
addedVEdges.push(vedge);
} else if (((!cnodes.includes(source) && !ccombos.includes(source))
&& (cnodes.includes(target) || ccombos.includes(target)))
|| (target.getModel().id === comboModel.id)) {
const edgeModel = edge.getModel();
if (edgeModel.isVEdge) {
this.removeItem(edge);
return;
}
let sourceModel = source.getModel();
if (!target.isVisible()) {
sourceModel = this.findById((sourceModel.parentId as string) || (sourceModel.comboId as string)).getModel();
}
const sourceId = sourceModel.id;
if (processedNodes[sourceId]) {
processedNodes[sourceId] += (edgeModel.size || 1);
return;
}
// the target is in the combo, the source is not
const vedge = this.addItem('vedge', {
target: comboModel.id,
source: sourceId,
isVEdge: true
});
processedNodes[sourceId] = edgeModel.size || 1;
addedVEdges.push(vedge);
}
});
addedVEdges.forEach(vedge => {
const vedgeModel = vedge.getModel();
if (vedgeModel.source === comboModel.id) {
this.updateItem(vedge, {
size: processedNodes[vedge.getTarget().get('id')]
})
} else if (vedgeModel.target === comboModel.id) {
this.updateItem(vedge, {
size: processedNodes[vedge.getSource().get('id')]
})
}
});
const itemController: ItemController = this.get('itemController');
itemController.collapseCombo(combo);
// update combo size
@ -1889,24 +1993,81 @@ export default class Graph extends EventEmitter implements IGraph {
if (isString(combo)) {
combo = this.findById(combo) as ICombo;
}
const comboModel = combo.getModel();
// add virtual edges
const edges = this.getEdges().concat(this.get('vedges'));
const cnodes = combo.getNodes();
const ccombos = combo.getCombos();
const processedNodes = {};
const addedVEdges = {};
edges.forEach(edge => {
const source = edge.getSource();
const target = edge.getTarget();
const sourceId = source.get('id');
const targetId = target.get('id');
if (((cnodes.includes(source) || ccombos.includes(source))
&& (!cnodes.includes(target) && !ccombos.includes(target)))
|| sourceId === comboModel.id) {
if (edge.getModel().isVEdge) {
this.removeItem(edge);
return;
}
// the source is in the combo, the target is not
if (!target.isVisible()) {
const oppsiteComboId: string = (target.getModel().comboId as string) || (target.getModel().parentId as string);
if (oppsiteComboId) {
const vedgeId = `${sourceId}-${oppsiteComboId}`;
if (processedNodes[vedgeId]) {
processedNodes[vedgeId] += (edge.getModel().size || 1);
this.updateItem(addedVEdges[vedgeId], {
size: processedNodes[vedgeId]
})
return;
}
const vedge = this.addItem('vedge', {
source: sourceId,
target: oppsiteComboId,
isVEdge: true
});
processedNodes[vedgeId] = edge.getModel().size || 1;
addedVEdges[vedgeId] = vedge;
}
}
} else if (((!cnodes.includes(source) && !ccombos.includes(source))
&& (cnodes.includes(target) || ccombos.includes(target)))
|| targetId === comboModel.id) {
if (edge.getModel().isVEdge) {
this.removeItem(edge);
return;
}
// the target is in the combo, the source is not
if (!source.isVisible()) {
const oppsiteComboId: string = (source.getModel().comboId as string) || (target.getModel().parentId as string);
if (oppsiteComboId) {
const vedgeId = `${oppsiteComboId}-${targetId}`;
if (processedNodes[vedgeId]) {
processedNodes[vedgeId] += (edge.getModel().size || 1);
this.updateItem(addedVEdges[vedgeId], {
size: processedNodes[vedgeId]
})
return;
}
const vedge = this.addItem('vedge', {
target: targetId,
source: oppsiteComboId,
isVEdge: true
});
processedNodes[vedgeId] = edge.getModel().size || 1;
addedVEdges[vedgeId] = vedge;
}
}
}
});
const itemController: ItemController = this.get('itemController');
itemController.expandCombo(combo);
const comboModel = combo.getModel();
// find the children from comboTrees
const comboTrees = this.get('comboTrees');
let children = [];
comboTrees.forEach((ctree: ComboTree) => {
let found = false;
traverseTreeUp<ComboTree>(ctree, child => {
if (comboModel.id === child.id) {
children = child.children;
}
return true;
});
});
// update combo size
// itemController.updateCombo(combo, children);
comboModel.collapsed = false;
}
@ -1972,7 +2133,7 @@ export default class Graph extends EventEmitter implements IGraph {
* comboTree Combo Combo
* @param {GraphData} data
*/
private sortCombos(data: GraphData) {
private sortCombos() {
const depthMap = [];
const dataDepthMap = {};
const comboTrees = this.get('comboTrees');
@ -1984,10 +2145,11 @@ export default class Graph extends EventEmitter implements IGraph {
return true;
});
});
const edges = data.edges;
edges && edges.forEach(edge => {
const sourceDepth: number = dataDepthMap[edge.source] || 0;
const targetDepth: number = dataDepthMap[edge.target] || 0;
const edges = this.getEdges().concat(this.get('vedges'));
edges && edges.forEach(edgeItem => {
const edge = edgeItem.getModel();
const sourceDepth: number = dataDepthMap[edge.source as string] || 0;
const targetDepth: number = dataDepthMap[edge.target as string] || 0;
const depth = Math.max(sourceDepth, targetDepth);
if (depthMap[depth]) depthMap[depth].push(edge.id);
else depthMap[depth] = [edge.id];

View File

@ -227,10 +227,10 @@ export interface IItemBase {
}
export interface IEdge extends IItemBase {
setSource(source: INode): void;
setTarget(target: INode): void;
getSource(): INode;
getTarget(): INode;
setSource(source: INode | ICombo): void;
setTarget(target: INode | ICombo): void;
getSource(): INode | ICombo;
getTarget(): INode | ICombo;
}
export interface INode extends IItemBase {

View File

@ -4,11 +4,14 @@ import Node from './node';
import { ComboConfig, IBBox, IShapeBase } from '../types';
import Global from '../global';
import { getBBox } from '../util/graphic';
import isNumber from '@antv/util/lib/is-number';
import { IItemBaseConfig } from '../interface/item';
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() {
@ -26,14 +29,23 @@ export default class Combo extends Node implements ICombo {
if (styles) {
// merge graph的item样式与数据模型中的样式
const newModel = model;
const itemType = this.getType();
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]
};
this.set(CACHE_SIZE, size);
newModel.style = Object.assign({}, styles, model.style, size);
let 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;
@ -202,10 +214,44 @@ export default class Combo extends Node implements ICombo {
public isOnlyMove(cfg?: ComboConfig): boolean {
return false;
}
/**
* item item matrix
* @return {Object} x,y,width,height, centerX, centerY
*/
public getBBox(): IBBox {
this.set(CACHE_CANVAS_BBOX, null);
let bbox: IBBox = this.calculateCanvasBBox();
// 计算 bbox 开销有些大,缓存
// let bbox: IBBox = this.get(CACHE_BBOX);
// if (!bbox) {
// bbox = this.getCanvasBBox();
// this.set(CACHE_BBOX, bbox);
// }
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

@ -1,6 +1,6 @@
import isNil from '@antv/util/lib/is-nil';
import isPlainObject from '@antv/util/lib/is-plain-object';
import { IEdge, INode } from '../interface/item';
import { IEdge, INode, ICombo } from '../interface/item';
import { EdgeConfig, IPoint, NodeConfig, SourceTarget, Indexable, ModelConfig } from '../types';
import Item from './item';
import Node from './node';
@ -174,21 +174,21 @@ export default class Edge extends Item implements IEdge {
return out;
}
public setSource(source: INode) {
public setSource(source: INode | ICombo) {
this.setEnd('source', source);
this.set('source', source);
}
public setTarget(target: INode) {
public setTarget(target: INode | ICombo) {
this.setEnd('target', target);
this.set('target', target);
}
public getSource(): INode {
public getSource(): INode | ICombo {
return this.get('source');
}
public getTarget(): INode {
public getTarget(): INode | ICombo {
return this.get('target');
}

View File

@ -699,6 +699,7 @@ export default class ItemBase implements IItemBase {
if (animate) {
group.stopAnimate();
}
this.clearCache();
group.remove();
(this._cfg as IItemBaseConfig | null) = null;
this.destroyed = true;

View File

@ -15,7 +15,7 @@ const CACHE_ANCHOR_POINTS = 'anchorPointsCache';
const CACHE_BBOX = 'bboxCache';
export default class Node extends Item implements INode {
private getNearestPoint(points: IPoint[], curPoint: IPoint): IPoint {
public getNearestPoint(points: IPoint[], curPoint: IPoint): IPoint {
let index = 0;
let nearestPoint = points[0];
let minDistance = distance(points[0], curPoint);

View File

@ -5,9 +5,10 @@
import { EdgeConfig, IPointTuple, NodeConfig, NodeIdxMap, ComboTree, ComboConfig } from '../types';
import { BaseLayout } from './layout';
import { isNumber, isArray, isFunction, clone } from '@antv/util';
import { isNumber, isArray, isFunction, clone, isNil } from '@antv/util';
import { Point } from '@antv/g-base';
import { traverseTreeUp } from '../util/graphic';
import Global from '../global';
type Node = NodeConfig & {
depth: number;
@ -44,7 +45,7 @@ export default class ComboForce extends BaseLayout {
/** 群组中心力大小 */
public comboGravity: number = 10;
/** 默认边长度 */
public linkDistance: number | ((d?: unknown) => number) = 50;
public linkDistance: number | ((d?: unknown) => number) = 10;
/** 每次迭代位移的衰减相关参数 */
public alpha: number = 1;
public alphaMin: number = 0.001;
@ -63,7 +64,7 @@ export default class ComboForce extends BaseLayout {
/** 是否开启 Combo 之间的防止重叠 */
public preventComboOverlap: boolean = false;
/** 防止重叠的碰撞力大小 */
public collideStrength: number = 0.2;
public collideStrength: number | undefined = undefined;
/** 防止重叠的碰撞力大小 */
public nodeCollideStrength: number | undefined = undefined;
/** 防止重叠的碰撞力大小 */
@ -82,9 +83,10 @@ export default class ComboForce extends BaseLayout {
public optimizeRangeFactor: number = 1;
/** 每次迭代的回调函数 */
public tick: () => void = () => { };
/** 根据边两端节点层级差距的调整函数 */
public depthAttractiveForceScale: number = 0.3; // ((d?: unknown) => number);
public depthRepulsiveForceScale: number = 2; // ((d?: unknown) => number);
/** 根据边两端节点层级差距的调整引力的因子,[0, 1] 代表层级差距越大,引力越小 */
public depthAttractiveForceScale: number = 0.5;
/** 根据边两端节点层级差距的调整斥力的因子,[1, Infinity] 代表层级差距越大,斥力越大 */
public depthRepulsiveForceScale: number = 2;
/** 内部计算参数 */
public nodes: Node[] = [];
@ -105,18 +107,24 @@ export default class ComboForce extends BaseLayout {
public getDefaultCfg() {
return {
maxIteration: 1000,
maxIteration: 100,
center: [0, 0],
gravity: 10,
speed: 1,
comboGravity: 10,
comboGravity: 30,
preventOverlap: false,
preventComboOverlap: true,
preventNodeOverlap: true,
nodeSpacing: undefined,
collideStrength: 0.2,
nodeCollideStrength: undefined,
comboCollideStrength: undefined,
comboSpacing: 5,
comboPadding: 10
collideStrength: undefined,
nodeCollideStrength: 0.5,
comboCollideStrength: 0.5,
comboSpacing: 20,
comboPadding: 10,
linkStrength: 0.2,
nodeStrength: 30,
linkDistance: 10,
};
}
/**
@ -125,7 +133,6 @@ export default class ComboForce extends BaseLayout {
public execute() {
const self = this;
const nodes = self.nodes;
const combos = self.combos;
const center = self.center;
self.comboTree = {
id: 'comboTreeRoot',
@ -238,14 +245,14 @@ export default class ComboForce extends BaseLayout {
self.comboMap = self.getComboMap();
const preventOverlap = self.preventOverlap;
if (preventOverlap) {
self.preventComboOverlap = true;
self.preventNodeOverlap = true;
}
self.preventComboOverlap = self.preventComboOverlap || preventOverlap;
self.preventNodeOverlap = self.preventNodeOverlap || preventOverlap;
const collideStrength = self.collideStrength;
if (!self.comboCollideStrength) self.comboCollideStrength = collideStrength;
if (!self.nodeCollideStrength) self.nodeCollideStrength = collideStrength;
if (!isNil(collideStrength)) {
self.comboCollideStrength = collideStrength;
self.nodeCollideStrength = collideStrength;
}
// get edge bias
for (let i = 0; i < edges.length; ++i) {
@ -334,7 +341,7 @@ export default class ComboForce extends BaseLayout {
let linkDistance = this.linkDistance;
let linkDistanceFunc;
if (!linkDistance) {
linkDistance = 50;
linkDistance = 10;
}
if (isNumber(linkDistance)) {
linkDistanceFunc = d => {
@ -595,7 +602,11 @@ export default class ComboForce extends BaseLayout {
if (c.maxX < nodeMaxX) c.maxX = nodeMaxX;
if (c.maxY < nodeMaxY) c.maxY = nodeMaxY;
});
c.r = Math.max(c.maxX - c.minX, c.maxY - c.minY) / 2 + comboSpacing(c) / 2 + comboPadding(c);
let minSize = self.oriComboMap[treeNode.id].size || Global.defaultCombo.size;
if (isArray(minSize)) minSize = minSize[0];
const maxLength = Math.max(c.maxX - c.minX, c.maxY - c.minY, minSize as number);
c.r = maxLength / 2 + comboSpacing(c) / 2 + comboPadding(c);
return true;
});
});
@ -614,6 +625,7 @@ export default class ComboForce extends BaseLayout {
traverseTreeUp<ComboTree>(comboTree, treeNode => {
if (!comboMap[treeNode.id] && !nodeMap[treeNode.id] && treeNode.id !== 'comboTreeRoot') return; // means it is hidden
const children = treeNode.children;
// 同个子树下的子 combo 间两两对比
if (children && children.length > 1) {
children.forEach((v, i) => {
if (v.itemType === 'node') return;
@ -642,6 +654,7 @@ export default class ComboForce extends BaseLayout {
const yl = vy * ll;
let rratio = ru2 / (rv2 + ru2);
let irratio = 1 - rratio;
// 两兄弟 combo 的子节点上施加斥力
vnodes.forEach(vn => {
if (vn.itemType !== 'node') return;
if (!nodeMap[vn.id]) return; // means it is hidden
@ -704,9 +717,9 @@ export default class ComboForce extends BaseLayout {
let { vx, vy } = vecMap[`${v.id}-${u.id}`];
const depthDiff = (Math.abs(u.depth - v.depth) + 1) || 1;
let depthDiff = (Math.abs(u.depth - v.depth) + 1) || 1;
if (u.comboId !== v.comboId) depthDiff++;
let depthParam = depthDiff ? Math.pow(scale, depthDiff) : 1;
// let depthParam = depthDiff ? scale * depthDiff : 1;
const params = nodeStrength(u) * alpha / vl * depthParam;
displacements[i].x += vx * params;
@ -763,13 +776,16 @@ export default class ComboForce extends BaseLayout {
const vecX = vx * l;
const vecY = vy * l;
const b = bias[i];
const depthDiff = Math.abs(u.depth - v.depth);
let depthDiff = Math.abs(u.depth - v.depth);
if (u.comboId === v.comboId) depthDiff / 2;
let depthParam = depthDiff ? Math.pow(scale, depthDiff) : 1;
if (u.comboId !== v.comboId && depthParam === 1) {
depthParam = scale;
} else if (u.comboId === v.comboId) {
depthParam = 1;
}
// depthParam = 1;
displacements[vIndex].x -= vecX * b * depthParam;
displacements[vIndex].y -= vecY * b * depthParam;
displacements[uIndex].x += vecX * (1 - b) * depthParam;

View File

@ -6,7 +6,7 @@ import GGroup from '@antv/g-canvas/lib/group';
import { IShape } from '@antv/g-canvas/lib/interfaces';
import { isArray, isNil, clone, isNumber } from '@antv/util';
import { ILabelConfig, ShapeOptions } from '../interface/shape';
import { Item, LabelStyle, NodeConfig, ModelConfig } from '../types';
import { Item, LabelStyle, NodeConfig, ModelConfig, ShapeStyle } from '../types';
import Global from '../global';
import Shape from './shape';
import { shapeBase } from './shapeBase';
@ -121,12 +121,12 @@ const singleCombo: ShapeOptions = {
});
return shape;
},
updateShape(cfg: NodeConfig, item: Item, keyShapeStyle: object) {
updateShape(cfg: NodeConfig, item: Item, keyShapeStyle: ShapeStyle) {
const keyShape = item.get('keyShape');
const animate = this.options.animate;
if (animate && keyShape.animate) {
keyShape.animate(keyShapeStyle, {
duration: 280,
duration: 200,
easing: 'easeLinear',
})
} else {

View File

@ -14,7 +14,7 @@ Shape.registerCombo(
// 自定义节点时的配置
options: {
size: [Global.defaultCombo.size[0], Global.defaultCombo.size[0]],
padding: 20,
padding: Global.defaultCombo.padding[0],
animate: true,
style: {
stroke: Global.defaultCombo.style.stroke,
@ -87,6 +87,12 @@ Shape.registerCombo(
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

View File

@ -275,8 +275,16 @@ Shape.registerCombo(
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 的相应配置覆盖。
@ -298,9 +306,17 @@ Shape.registerCombo(
},
updateShape(cfg: ComboConfig, item: Item, keyShapeStyle: object) {
const keyShape = item.get('keyShape');
keyShape.attr({
...keyShapeStyle,
});
const animate = this.options.animate;
if (animate && keyShape.animate) {
keyShape.animate(keyShapeStyle, {
duration: 200,
easing: 'easeLinear',
})
} else {
keyShape.attr({
...keyShapeStyle,
});
}
(this as any).updateLabel(cfg, item);
(this as any).updateCollapseIcon(cfg, item, keyShapeStyle)

View File

@ -598,7 +598,7 @@ export interface IG6GraphEvent extends GraphEvent {
// Node Edge Combo 实例
export type Item = INode | IEdge | ICombo;
export type ITEM_TYPE = 'node' | 'edge' | 'combo' | 'group';
export type ITEM_TYPE = 'node' | 'edge' | 'combo' | 'group' | 'vedge';
export type NodeIdxMap = {
[key: string]: number;

View File

@ -506,13 +506,15 @@ export const plainCombosToTrees = (array: ComboConfig[], nodes?: INode[]) => {
tree.depth = 0;
traverse<ComboTree>(tree, child => {
let parent;
if (addedMap[child.id]['itemType'] === 'node') {
const itemType = addedMap[child.id]['itemType'];
if (itemType === 'node') {
parent = addedMap[child['comboId'] as string];
} else {
parent = addedMap[child.parentId];
}
if (parent) {
child.depth = parent.depth + 1;
if (itemType === 'node') child.depth = parent.depth + 1;
else child.depth = parent.depth + 2;
} else {
child.depth = 0;
}

View File

@ -600,6 +600,10 @@ const Tutorial = () => {
'https://gw.alipayobjects.com/os/basement_prod/6cae02ab-4c29-44b2-b1fd-4005688febcb.json',
);
const data = await response.json();
<<<<<<< HEAD
=======
console.log(data);
>>>>>>> feat: collapse expand combo to add virtual edges to represent the edges between collapsed combos.
const nodes = data.nodes;
const edges = data.edges;
nodes.forEach(node => {

View File

@ -3,8 +3,10 @@ import { storiesOf } from '@storybook/react';
import React from 'react';
import DefaultCombo from './component/default-combo';
import RegisterCombo from './component/register-combo';
import CollapseExpand from './component/collapse-expand-combo'
import ComboLayoutCollapseExpand from './component/combo-layout-collapse-expand'
import CollapseExpand from './component/collapse-expand-combo';
import ComboLayoutCollapseExpand from './component/combo-layout-collapse-expand';
import CollapseExpandVEdge from './component/collapse-expand-vedge';
import ComboCollapseExpandTree from './component/combo-collapse-expand-tree';
export default { title: 'Combo' };
@ -20,4 +22,10 @@ storiesOf('Combo', module)
))
.add('force + collapse expand', () => (
<ComboLayoutCollapseExpand />
))
.add('collapse expand vedge', () => (
<CollapseExpandVEdge />
))
.add('collapse expand tree', () => (
<ComboCollapseExpandTree />
));

View File

@ -0,0 +1,177 @@
import React, { useEffect } from 'react';
import G6 from '../../../src';
import { IGraph } from '../../../src/interface/graph';
let graph: IGraph = null;
const colors = {
a: '#BDD2FD',
b: '#BDEFDB',
c: '#C2C8D5',
d: '#FBE5A2',
e: '#F6C3B7',
f: '#B6E3F5',
g: '#D3C6EA',
h: '#FFD8B8',
i: '#AAD8D8',
j: '#FFD6E7',
};
const testData = {
nodes: [
{
id: '0',
label: '0',
comboId: 'a',
x: 100,
y: 100
},
{
id: '1',
label: '1',
comboId: 'a',
x: 150,
y: 140
},
{
id: '2',
label: '2',
comboId: 'b',
x: 300,
y: 200
},
{
id: '3',
label: '3',
comboId: 'b',
x: 370,
y: 260
},
{
id: '4',
label: '4',
comboId: 'c',
x: 360,
y: 510
},
// {
// id: '5',
// label: '5',
// comboId: 'd',
// x: 420,
// y: 510
// },
],
edges: [
// {
// source: 'a',
// target: 'b',
// size: 3,
// style: {
// stroke: 'red'
// }
// },
// {
// source: 'b',
// target: '1',
// size: 3,
// style: {
// stroke: 'blue'
// }
// },
{
source: '0',
target: '1',
},
{
source: '0',
target: '2',
},
{
source: '0',
target: '3',
},
// {
// source: '3',
// target: '5',
// },
{
source: '4',
target: '1',
}
],
combos: [{
id: 'a',
label: 'combo a'
}, {
id: 'b',
label: 'combo b'
}, {
id: 'c',
label: 'combo c'
},
// {
// id: 'd',
// label: 'combo d',
// parentId: 'c'
// }
]
};
const CollapseExpandVEdge = () => {
const container = React.useRef();
useEffect(() => {
if (!graph) {
graph = new G6.Graph({
container: container.current as string | HTMLElement,
width: 1000,
height: 800,
fitView: true,
modes: {
default: ['drag-canvas', 'drag-node', 'zoom-canvas', 'collapse-expand-combo'],
},
defaultEdge: {
size: 1,
color: '#666',
},
defaultCombo: {
type: 'circle',
//padding: 1
},
groupByTypes: false,
//animate: true
});
graph.node(node => {
const color = colors[node.comboId as string];
return {
size: 20,
style: {
lineWidth: 2,
stroke: '#ccc',
fill: color,
},
}
});
graph.combo(combo => {
const color = colors[combo.id as string];
return {
// size: 80,
style: {
lineWidth: 2,
stroke: color,
fillOpacity: 0.8
},
}
});
graph.data(testData);//testData_pos
graph.render();
}
});
return <div ref={container}></div>;
};
export default CollapseExpandVEdge;

View File

@ -0,0 +1,141 @@
import React, { useEffect } from 'react';
import G6 from '../../../src';
import { IGraph } from '../../../src/interface/graph';
let graph: IGraph = null;
const colors = {
a: '#BDD2FD',
b: '#BDEFDB',
c: '#C2C8D5',
d: '#FBE5A2',
e: '#F6C3B7',
f: '#B6E3F5',
g: '#D3C6EA',
h: '#FFD8B8',
i: '#AAD8D8',
j: '#FFD6E7',
};
const data = {
"id": "Modeling Methods",
"children": [
{
"id": "Classification",
comboId: 'a',
"children": [
{ "id": "Logistic regression", comboId: 'a', },
{ "id": "Linear discriminant analysis", comboId: 'a' },
{ "id": "Rules", comboId: 'a' },
{ "id": "Decision trees", comboId: 'a' },
{ "id": "Naive Bayes", comboId: 'a' },
{ "id": "K nearest neighbor", comboId: 'a' },
{ "id": "Probabilistic neural network", comboId: 'a' },
{ "id": "Support vector machine", comboId: 'a' }
]
},
{
"id": "Consensus",
"children": [
{
"id": "Models diversity",
comboId: 'a',
"children": [
{ "id": "Different initializations", comboId: 'a' },
{ "id": "Different parameter choices", comboId: 'a' },
{ "id": "Different architectures", comboId: 'a' },
{ "id": "Different modeling methods", comboId: 'a' },
{ "id": "Different training sets", comboId: 'a' },
{ "id": "Different feature sets", comboId: 'a' }
]
},
{
"id": "Methods",
comboId: 'b',
"children": [
{ "id": "Classifier selection", comboId: 'b' },
{ "id": "Classifier fusion", comboId: 'b' }
]
},
{
"id": "Common",
comboId: 'c',
"children": [
{ "id": "Bagging", comboId: 'c' },
{ "id": "Boosting", comboId: 'c' },
{ "id": "AdaBoost", comboId: 'c' }
]
}
]
},
{
"id": "Regression",
comboId: 'd',
"children": [
{ "id": "Multiple linear regression", comboId: 'd' },
{ "id": "Partial least squares", comboId: 'd' },
{ "id": "Multi-layer feedforward neural network" },
{ "id": "General regression neural network" },
{ "id": "Support vector regression" }
]
}
],
combos: []
};
const ComboCollapseExpandTree = () => {
const container = React.useRef();
useEffect(() => {
if (!graph) {
graph = new G6.TreeGraph({
container: container.current as string | HTMLElement,
width: 1000,
height: 800,
fitView: true,
modes: {
default: ['drag-canvas', 'drag-node', 'zoom-canvas', 'collapse-expand-combo'],
},
layout: {
type: 'compactBox',
},
defaultEdge: {
size: 1,
color: '#666',
},
defaultCombo: {
type: 'circle',
padding: 50
},
groupByTypes: false,
//animate: true
});
graph.combo(combo => {
const color = colors[combo.id as string];
return {
// size: 80,
style: {
lineWidth: 2,
stroke: color,
fillOpacity: 0.8
},
}
});
data.combos = [{
id: 'a'
}, {
id: 'b'
}, {
id: 'c'
}, {
id: 'd'
}];
graph.data(data);
graph.render();
}
});
return <div ref={container}></div>;
};
export default ComboCollapseExpandTree;

View File

@ -628,6 +628,22 @@ const testData = {
},
],
edges: [
{
source: 'a',
target: 'b',
size: 3,
style: {
stroke: 'red'
}
},
{
source: 'a',
target: '33',
size: 3,
style: {
stroke: 'blue'
}
},
{
source: '0',
target: '1',
@ -922,6 +938,8 @@ const testData2 = {
target: 'node0'
}]
}
const testData_pos = {"nodes":[{"id":"0","x":519.9756011152062,"y":312.3748588848735,"comboId":"a","label":"0"},{"id":"1","x":516.9130868036522,"y":300.01298088318964,"comboId":"a","label":"1"},{"id":"2","x":532.135761126721,"y":292.4828444555691,"comboId":"a","label":"2"},{"id":"3","x":503.08139107396323,"y":274.4859385079485,"comboId":"a","label":"3"},{"id":"4","x":515.7858143951453,"y":288.9459640448524,"comboId":"a","label":"4"},{"id":"5","x":523.1699237148887,"y":271.80995614771984,"comboId":"a","label":"5"},{"id":"6","x":542.5161585695183,"y":269.44295161957444,"comboId":"a","label":"6"},{"id":"7","x":540.3068131495662,"y":312.3305908975899,"comboId":"a","label":"7"},{"id":"8","x":540.1624078071186,"y":332.7802388427267,"comboId":"a","label":"8"},{"id":"9","x":497.2349390488828,"y":294.6747309770873,"comboId":"a","label":"9"},{"id":"10","x":477.27853085824415,"y":310.52154966368073,"comboId":"a","label":"10"},{"id":"11","x":498.2935956770408,"y":315.7336916123234,"comboId":"a","label":"11"},{"id":"12","x":511.8111914629553,"y":331.4660202536512,"comboId":"a","label":"12"},{"id":"13","x":719.9144466443125,"y":657.5827638668379,"comboId":"b","label":"13"},{"id":"14","x":705.1704215932026,"y":623.1349052942683,"comboId":"b","label":"14"},{"id":"15","x":748.888997314242,"y":693.5395656445546,"comboId":"b","label":"15"},{"id":"16","x":691.790150377325,"y":647.2895235684261,"comboId":"b","label":"16"},{"id":"17","x":700.453909738154,"y":666.3168884837505,"comboId":"b","label":"17"},{"id":"18","x":377.3258580235555,"y":373.66554381437624,"comboId":"c","label":"18"},{"id":"19","x":361.8579868952546,"y":350.20312804345934,"comboId":"c","label":"19"},{"id":"20","x":380.71103876474103,"y":332.78105195763504,"comboId":"c","label":"20"},{"id":"21","x":369.1201243167846,"y":332.02692181101764,"comboId":"c","label":"21"},{"id":"22","x":377.8110758618865,"y":350.0158128077823,"comboId":"c","label":"22"},{"id":"23","x":359.4471586647803,"y":335.9621566320535,"comboId":"c","label":"23"},{"id":"24","x":372.74841352346505,"y":314.6483478876071,"comboId":"c","label":"24"},{"id":"25","x":352.7557971300988,"y":331.4794537088405,"comboId":"c","label":"25"},{"id":"26","x":344.9855032420906,"y":350.16490710174355,"comboId":"c","label":"26"},{"id":"27","x":347.46959717648184,"y":313.6154232044856,"comboId":"c","label":"27"},{"id":"28","x":361.4870734755764,"y":322.1957223205074,"comboId":"c","label":"28"},{"id":"29","x":329.63246460052113,"y":320.22474499903063,"comboId":"c","label":"29"},{"id":"30","x":339.92007768802193,"y":335.1429986552043,"comboId":"c","label":"30"},{"id":"31","x":680.1030572348665,"y":679.0745385883383,"comboId":"d","label":"31"},{"id":"32","x":701.6575625058663,"y":703.0463672244064,"comboId":"d","label":"32"},{"id":"33","x":658.0840704258677,"y":660.8269175948863,"comboId":"d","label":"33"}],"combos":[{"id":"a","label":"combo a"},{"id":"b","label":"combo b"},{"id":"c","label":"combo c"},{"id":"d","parentId":"b","label":"combo d"}]}
const ComboLayoutCollapseExpand = () => {
const container = React.useRef();
useEffect(() => {
@ -929,36 +947,24 @@ const ComboLayoutCollapseExpand = () => {
graph = new G6.Graph({
container: container.current as string | HTMLElement,
width: 1000,
height: 800,
// fitView: true,
height: 500,
fitView: true,
modes: {
default: ['drag-canvas', 'drag-node', 'zoom-canvas', 'collapse-expand-combo'],
},
layout: {
type: 'comboForce',
linkDistance: 10,
comboGravity: 30,
nodeSpacing: 1,
nodeStrength: 30,
linkStrength: 0.1,
preventNodeOverlap: true,
preventComboOverlap: true,
// preventOverlap: true,
comboCollideStrength: 0.5,
nodeCollideStrength: 0.1,
maxIteration: 100,
comboPadding: 10,
comboSpacing: 30
type: 'comboForce'
},
defaultEdge: {
size: 3,
size: 1,
color: '#666',
},
defaultCombo: {
type: 'rect'
type: 'circle',
padding: 10
},
groupByTypes: false,
animate: true
// animate: true
});
graph.node(node => {
@ -975,8 +981,7 @@ const ComboLayoutCollapseExpand = () => {
graph.combo(combo => {
const color = colors[combo.id as string];
return {
size: 20,
// padding: 5,
// size: 80,
style: {
lineWidth: 2,
stroke: color,
@ -985,14 +990,35 @@ const ComboLayoutCollapseExpand = () => {
}
});
fetch(
'https://gw.alipayobjects.com/os/basement_prod/7bacd7d1-4119-4ac1-8be3-4c4b9bcbc25f.json',
)
.then(res => res.json())
.then(data => {
graph.data(testData);
graph.render();
});
graph.data(testData);//testData_pos
graph.render();
// const outputData = {
// nodes: [],
// combos: []
// };
// graph.getNodes().forEach((n: any) => {
// const node = n.getModel();
// console.log(node.x, node.y)
// outputData.nodes.push({
// id: node.id,
// x: node.x,
// y: node.y,
// comboId: node.comboId,
// label: node.label
// });
// })
// testData.combos.forEach((combo: any) => {
// outputData.combos.push({
// id: combo.id,
// parentId: combo.parentId,
// label: combo.label
// });
// });
// console.log(JSON.stringify(outputData));
// graph.on('canvas:click', e => {
// graph.changeData(testData_pos);
// });
}
});
return <div ref={container}></div>;

View File

@ -497,13 +497,6 @@ const ComboForceLayout = () => {
modes: {
default: ['drag-canvas', 'drag-node', 'zoom-canvas'],
},
layout: {
type: 'comboForce',
linkDistance: 1000,
fitView: true,
modes: {
default: ['drag-canvas', 'drag-node', 'zoom-canvas'],
},
layout: {
type: 'comboForce',
linkDistance: 100,

View File

@ -51,7 +51,7 @@ describe('combo node test', () => {
group,
);
canvas.draw();
expect(shape.attr('r')).toBe(40);
expect(shape.attr('r')).toBe(45); // size / 2 + padding
expect(group.getCount()).toBe(1);
});
@ -156,7 +156,11 @@ describe('combo node test', () => {
fill: 'steelblue'
}
});
expect(shape.attr('fill')).toBe('steelblue');
// since the update is animated, check it after 300ms
setTimeout(() => {
expect(shape.attr('fill')).toBe('steelblue');
}, 300);
});
it('active', () => {