feat: animations (#4439)

* feat: animations

* feat: zoom level rendering and size keeping; feat: animate at buildIn, show, hide, update, buildOut

* chore: refine

* chore: refine
This commit is contained in:
Yanyan Wang 2023-05-05 10:14:48 +08:00 committed by GitHub
parent 05948c5e7d
commit 2f749bc084
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 5543 additions and 390 deletions

View File

@ -48,8 +48,8 @@
"fix": "eslint ./src ./tests --fix && prettier ./src ./tests --write ",
"test": "jest",
"size": "limit-size",
"test-live": "DEBUG_MODE=1 jest --watch ./tests/unit/behaviors/zoom-canvas-spec.ts",
"test-behavior": "DEBUG_MODE=1 jest --watch ./tests/unit/click-select-spec.ts"
"test-live": "DEBUG_MODE=1 jest --watch ./tests/unit/show-animate-spec.ts",
"test-behavior": "DEBUG_MODE=1 jest --watch ./tests/unit/item-3d-spec.ts"
},
"lint-staged": {
"*.{ts,tsx}": [

View File

@ -16,7 +16,7 @@ export const DEFAULT_SHAPE_STYLE = {
fillOpacity: 1,
shadowColor: undefined,
shadowBlur: 0,
lineDash: undefined,
lineDash: ['100%', 0],
zIndex: 0,
};
/** Default text style to avoid shape value missing */

View File

@ -1,12 +1,13 @@
import { Group } from '@antv/g';
import { clone } from '@antv/util';
import { clone, throttle } from '@antv/util';
import { EdgeDisplayModel, EdgeModel, NodeModelData } from '../types';
import { EdgeModelData } from '../types/edge';
import { DisplayMapper, State } from '../types/item';
import { DisplayMapper, State, ZoomStrategyObj } from '../types/item';
import { updateShapes } from '../util/shape';
import Item from './item';
import Node from './node';
import { EdgeStyleSet } from 'types/theme';
import { animateShapes } from '../util/animate';
import { EdgeStyleSet } from '../types/theme';
interface IProps {
model: EdgeModel;
@ -18,7 +19,12 @@ interface IProps {
};
sourceItem: Node;
targetItem: Node;
themeStyles: EdgeStyleSet;
zoom?: number;
theme: {
styles: EdgeStyleSet;
zoomStrategy: ZoomStrategyObj;
};
onframe?: Function;
}
export default class Edge extends Item {
@ -45,6 +51,7 @@ export default class Edge extends Item {
displayModel: EdgeDisplayModel,
diffData?: { previous: EdgeModelData; current: EdgeModelData },
diffState?: { previous: State[]; current: State[] },
onfinish: Function = () => {},
) {
// get the end points
const { x: sx, y: sy, z: sz } = this.sourceItem.model.data as NodeModelData;
@ -52,6 +59,9 @@ export default class Edge extends Item {
const sourcePoint = this.sourceItem.getAnchorPoint({ x: tx, y: ty, z: tz });
const targetPoint = this.targetItem.getAnchorPoint({ x: sx, y: sy, z: sz });
this.renderExt.mergeStyles(displayModel);
const firstRendering = !this.shapeMap?.keyShape;
this.renderExt.setSourcePoint(sourcePoint);
this.renderExt.setTargetPoint(targetPoint);
const shapeMap = this.renderExt.draw(
displayModel,
sourcePoint,
@ -63,20 +73,55 @@ export default class Edge extends Item {
// add shapes to group, and update shapeMap
this.shapeMap = updateShapes(this.shapeMap, shapeMap, this.group);
const { haloShape, labelShape, labelBackgroundShape } = this.shapeMap;
// handle shape's and group's animate
const { animates, disableAnimate } = displayModel.data;
const usingAnimates = { ...animates };
let targetStyles = this.renderExt.mergedStyles;
const { haloShape, labelShape } = this.shapeMap;
haloShape?.toBack();
labelShape?.toFront();
super.draw(displayModel, diffData, diffState);
this.renderExt.updateCache(this.shapeMap);
if (firstRendering) {
// update the transform
this.renderExt.onZoom(this.shapeMap, this.zoom);
}
// terminate previous animations
this.stopAnimations();
const timing = firstRendering ? 'buildIn' : 'update';
// handle shape's animate
if (!disableAnimate && usingAnimates[timing]?.length) {
this.animations = animateShapes(
usingAnimates,
targetStyles, // targetStylesMap
this.shapeMap, // shapeMap
this.group,
firstRendering ? 'buildIn' : 'update',
this.changedStates,
this.animateFrameListener,
() => onfinish(displayModel.id),
);
}
}
/**
* Sometimes no changes on edge data, but need to re-draw it
* e.g. source and target nodes' position changed
*/
public forceUpdate() {
this.draw(this.displayModel);
}
public forceUpdate = throttle(
() => {
if (!this.destroyed) this.draw(this.displayModel);
},
16,
{
leading: true,
trailing: true,
},
);
/**
* Update end item for item and re-draw the edge
@ -98,6 +143,7 @@ export default class Edge extends Item {
sourceItem: Node,
targetItem: Node,
onlyKeyShape?: boolean,
disableAnimate?: boolean,
) {
if (onlyKeyShape) {
const clonedKeyShape = this.shapeMap.keyShape.cloneNode();
@ -106,15 +152,21 @@ export default class Edge extends Item {
containerGroup.appendChild(clonedGroup);
return clonedGroup;
}
const clonedModel = clone(this.model);
clonedModel.data.disableAnimate = disableAnimate;
return new Edge({
model: clone(this.model),
model: clonedModel,
renderExtensions: this.renderExtensions,
sourceItem,
targetItem,
containerGroup,
mapper: this.mapper,
stateMapper: this.stateMapper,
themeStyles: clone(this.themeStyles),
zoom: this.zoom,
theme: {
styles: clone(this.themeStyles),
zoomStrategy: this.zoomStrategy,
},
});
}
}

View File

@ -1,5 +1,5 @@
import { Group, DisplayObject, AABB } from '@antv/g';
import { clone, isFunction } from '@antv/util';
import { Group, DisplayObject, AABB, IAnimation } from '@antv/g';
import { clone, isFunction, throttle } from '@antv/util';
import { OTHER_SHAPES_FIELD_NAME, RESERVED_SHAPE_IDS } from '../constant';
import { EdgeShapeMap } from '../types/edge';
import {
@ -11,6 +11,7 @@ import {
ItemShapeStyles,
ITEM_TYPE,
State,
ZoomStrategyObj,
} from '../types/item';
import { NodeShapeMap } from '../types/node';
import { EdgeStyleSet, NodeStyleSet } from '../types/theme';
@ -18,48 +19,75 @@ import { isArrayOverlap } from '../util/array';
import { mergeStyles, updateShapes } from '../util/shape';
import { isEncode } from '../util/type';
import { DEFAULT_MAPPER } from '../util/mapper';
import {
getShapeAnimateBeginStyles,
animateShapes,
GROUP_ANIMATE_STYLES,
} from '../util/animate';
import { AnimateTiming, IAnimates } from '../types/animate';
export default abstract class Item implements IItem {
public destroyed = false;
// inner data model
/** Inner model. */
public model: ItemModel;
// display data model
/** Display model, user will not touch it. */
public displayModel: ItemDisplayModel;
/** The mapper configured at graph with field name 'node' / 'edge' / 'combo'. */
public mapper: DisplayMapper;
/** The state sstyle mapper configured at traph with field name 'nodeState' / 'edgeState' / 'comboState'. */
public stateMapper: {
[stateName: string]: DisplayMapper;
};
/** The graphic group for item drawing. */
public group: Group;
/** The keyShape of the item. */
public keyShape: DisplayObject;
// render extension for this item
/** render extension for this item. */
public renderExt;
/** Visibility. */
public visible = true;
/** The states on the item. */
public states: {
name: string;
value: string | boolean;
}[] = [];
/** The map caches the shapes of the item. The key is the shape id, the value is the g shape. */
public shapeMap: NodeShapeMap | EdgeShapeMap = {
keyShape: undefined,
};
/** Set to different value in implements */
public afterDrawShapeMap = {};
/** Set to different value in implements. */
public type: ITEM_TYPE;
public renderExtensions: any; // TODO
/** Cache the animation instances to stop at next lifecycle. */
public animations: IAnimation[];
/** Cache the dirty tags for states when data changed, to re-map the state styles when state changed */
private stateDirtyMap: { [stateName: string]: boolean } = {};
private cacheStateStyles: { [stateName: string]: ItemShapeStyles } = {};
public themeStyles: {
default?: ItemShapeStyles;
[stateName: string]: ItemShapeStyles;
};
/** The zoom strategy to show and hide shapes according to their showLevel. */
public zoomStrategy: ZoomStrategyObj;
/** Last zoom ratio. */
public zoom: number;
/** Cache the chaging states which are not consomed by draw */
public changedStates: string[];
/** The listener for the animations frames. */
public onframe: Function;
private device: any; // for 3d
/** Cache the dirty tags for states when data changed, to re-map the state styles when state changed */
private stateDirtyMap: { [stateName: string]: boolean } = {};
private cacheStateStyles: { [stateName: string]: ItemShapeStyles } = {};
private cacheHiddenShape: { [shapeId: string]: boolean } = {};
// TODO: props type
constructor(props) {
this.device = props.device;
this.onframe = props.onframe;
}
/** Initiate the item. */
public init(props) {
const {
model,
@ -67,7 +95,8 @@ export default abstract class Item implements IItem {
mapper,
stateMapper,
renderExtensions,
themeStyles = {},
zoom = 1,
theme = {},
} = props;
this.group = new Group();
this.group.setAttribute('data-item-type', this.type);
@ -75,30 +104,40 @@ export default abstract class Item implements IItem {
containerGroup.appendChild(this.group);
this.model = model;
this.mapper = mapper;
this.zoom = zoom;
this.stateMapper = stateMapper;
this.displayModel = this.getDisplayModelAndChanges(model).model;
this.renderExtensions = renderExtensions;
const { type = this.type === 'node' ? 'circle-node' : 'line-edge' } =
this.displayModel.data;
const RenderExtension = renderExtensions.find((ext) => ext.type === type);
this.themeStyles = themeStyles;
this.themeStyles = theme.styles;
this.renderExt = new RenderExtension({
themeStyles: this.themeStyles.default,
zoomStrategy: theme.zoomStrategy,
device: this.device,
});
}
/**
* Draws the shapes.
* @internal
* */
public draw(
displayModel: ItemDisplayModel,
diffData?: { previous: ItemModelData; current: ItemModelData },
diffState?: { previous: State[]; current: State[] },
onfinish: Function = () => {},
) {
// call this.renderExt.draw in extend implementations
const afterDrawShapes =
this.renderExt.afterDraw?.(displayModel, this.shapeMap) || {};
this.afterDrawShapeMap =
this.renderExt.afterDraw?.(displayModel, {
...this.shapeMap,
...this.afterDrawShapeMap,
}) || {};
this.shapeMap = updateShapes(
this.shapeMap,
afterDrawShapes,
this.afterDrawShapeMap,
this.group,
false,
(id) => {
@ -111,17 +150,30 @@ export default abstract class Item implements IItem {
return true;
},
);
this.changedStates = [];
}
/**
* Updates the shapes.
* @internal
* */
public update(
model: ItemModel,
diffData?: { previous: ItemModelData; current: ItemModelData },
isReplace?: boolean,
themeStyles?: NodeStyleSet | EdgeStyleSet,
itemTheme?: {
styles: NodeStyleSet | EdgeStyleSet;
zoomStrategy: ZoomStrategyObj;
},
onlyMove?: boolean,
onfinish?: Function,
) {
// 1. merge model into this model
this.model = model;
if (themeStyles) this.themeStyles = themeStyles;
if (itemTheme) {
this.themeStyles = itemTheme.styles;
this.zoomStrategy = itemTheme.zoomStrategy;
}
// 2. map new merged model to displayModel, keep prevModel and newModel for 3.
const { model: displayModel, typeChange } = this.getDisplayModelAndChanges(
this.model,
@ -130,6 +182,11 @@ export default abstract class Item implements IItem {
);
this.displayModel = displayModel;
if (onlyMove) {
this.updatePosition(displayModel, diffData, onfinish);
return;
}
if (typeChange) {
Object.values(this.shapeMap).forEach((child) => child.destroy());
this.shapeMap = { keyShape: undefined };
@ -140,22 +197,37 @@ export default abstract class Item implements IItem {
);
this.renderExt = new RenderExtension({
themeStyles: this.themeStyles.default,
zoomStrategy: this.zoomStrategy,
device: this.device,
});
} else {
this.renderExt.themeStyles = this.themeStyles.default;
this.renderExt.zoomStrategy = this.zoomStrategy;
}
// 3. call element update fn from useLib
if (this.states?.length) {
this.drawWithStates(this.states);
this.drawWithStates(this.states, onfinish);
} else {
this.draw(this.displayModel, diffData);
this.draw(this.displayModel, diffData, undefined, onfinish);
}
// 4. tag all the states with 'dirty', for state style regenerating when state changed
this.stateDirtyMap = {};
this.states.forEach(({ name }) => (this.stateDirtyMap[name] = true));
}
/**
* Update the group's position, e.g. node, combo.
* @param displayModel
* @param diffData
* @param onfinish
* @returns
*/
public updatePosition(
displayModel: ItemDisplayModel,
diffData?: { previous: ItemModelData; current: ItemModelData },
onfinish?: Function,
) {}
/**
* Maps (mapper will be function, value, or encode format) model to displayModel and find out the shapes to be update for incremental updating.
* @param model inner model
@ -203,15 +275,13 @@ export default abstract class Item implements IItem {
// === fields' values in mapper are final value or Encode ===
const dataChangedFields = isReplace
? undefined
: // ? Array.from(new Set(Object.keys(current).concat(Object.keys(previous)))) // all the fields for replacing all data
Object.keys(current).concat(Object.keys(otherFields)); // only fields in current data for partial updating
: Object.keys(current).concat(Object.keys(otherFields)); // only fields in current data for partial updating
let typeChange = false;
const { data, ...otherProps } = innerModel;
const displayModelData = defaultMapper(innerModel).data; //clone(data);
// const defaultMappedModel = defaultMapper(innerModel);
Object.keys(mapper).forEach((fieldName) => {
debugger;
let subMapper = mapper[fieldName];
const isReservedShapeId = RESERVED_SHAPE_IDS.includes(fieldName);
const isShapeId =
@ -287,30 +357,96 @@ export default abstract class Item implements IItem {
return this.type;
}
public show() {
/** Show the item. */
public show(animate: boolean = true) {
// TODO: utilize graphcore's view
this.group.show();
this.stopAnimations();
const { animates = {} } = this.displayModel.data;
if (animate && animates.show?.length) {
const showAnimateFieldsMap: any = {};
Object.values(animates.show).forEach((animate) => {
const { shapeId = 'group' } = animate;
showAnimateFieldsMap[shapeId] = (
showAnimateFieldsMap[shapeId] || []
).concat(animate.fields);
});
const targetStyleMap = {};
Object.keys(this.shapeMap).forEach((id) => {
const shape = this.shapeMap[id];
if (!this.cacheHiddenShape[id]) {
// set the animate fields to initial value
if (showAnimateFieldsMap[id]) {
targetStyleMap[id] = targetStyleMap[id] || {};
const beginStyle = getShapeAnimateBeginStyles(shape);
showAnimateFieldsMap[id].forEach((field) => {
if (beginStyle.hasOwnProperty(field)) {
targetStyleMap[id][field] = shape.style[field];
shape.style[field] = beginStyle[field];
}
});
}
shape.show();
}
});
if (showAnimateFieldsMap.group) {
showAnimateFieldsMap.group.forEach((field) => {
const usingField = field === 'size' ? 'transform' : field;
if (GROUP_ANIMATE_STYLES[0].hasOwnProperty(usingField)) {
this.group.style[usingField] = GROUP_ANIMATE_STYLES[0][usingField];
}
});
}
this.animations = this.runWithAnimates(animates, 'show', targetStyleMap);
} else {
Object.keys(this.shapeMap).forEach((id) => {
const shape = this.shapeMap[id];
if (!this.cacheHiddenShape[id]) shape.show();
});
}
this.visible = true;
}
public hide() {
/** Hides the item. */
public hide(animate: boolean = true) {
// TODO: utilize graphcore's view
this.group.hide();
this.stopAnimations();
const func = () => {
Object.keys(this.shapeMap).forEach((id) => {
const shape = this.shapeMap[id];
if (this.visible && !shape.isVisible())
this.cacheHiddenShape[id] = true;
shape.hide();
});
};
const { animates = {} } = this.displayModel.data;
if (animate && animates.hide?.length) {
this.animations = this.runWithAnimates(animates, 'hide', undefined, func);
} else {
// 2. clear group and remove group
func();
}
this.visible = false;
}
/** Returns the visibility of the item. */
public isVisible() {
return this.visible;
}
public toBack() {
this.group.toBack();
}
/** Puts the item to the front in its graphic group. */
public toFront() {
this.group.toFront();
}
/** Puts the item to the back in its graphic group. */
public toBack() {
this.group.toBack();
}
/**
* The state value for the item, false if the item does not have the state.
* @param state state name
@ -346,6 +482,7 @@ export default abstract class Item implements IItem {
this.renderExt.setState(state, value, this.shapeMap);
return;
}
this.changedStates = [state];
this.drawWithStates(previousStates);
}
@ -373,6 +510,7 @@ export default abstract class Item implements IItem {
}));
}
this.states = newStates;
this.changedStates = changedStates.map((state) => state.name);
// if the renderExt overwrote the setState, run the custom setState instead of the default
if (this.renderExt.constructor.prototype.hasOwnProperty('setState')) {
changedStates.forEach(({ name, value }) =>
@ -391,13 +529,40 @@ export default abstract class Item implements IItem {
return this.states;
}
public destroy() {
// TODO: 1. stop animations
// 2. clear group and remove group
this.group.destroy();
this.model = null;
this.displayModel = null;
this.destroyed = true;
/**
* Run some thing with animations, e.g. hide, show, destroy.
* @param animates
* @param timing
* @param targetStyleMap
* @param callback
* @returns
*/
private runWithAnimates(
animates: IAnimates,
timing: AnimateTiming,
targetStyleMap: Object,
callback: Function = () => {},
) {
let targetStyle = {};
if (!targetStyleMap) {
Object.keys(this.shapeMap).forEach((shapeId) => {
targetStyle[shapeId] = getShapeAnimateBeginStyles(
this.shapeMap[shapeId],
);
});
} else {
targetStyle = targetStyleMap;
}
return animateShapes(
animates,
targetStyle, // targetStylesMap
this.shapeMap, // shapeMap
this.group,
timing,
[],
() => {},
callback,
);
}
/**
@ -405,7 +570,7 @@ export default abstract class Item implements IItem {
* @param previousStates previous states
* @returns
*/
private drawWithStates(previousStates: State[]) {
private drawWithStates(previousStates: State[], onfinish?: Function) {
const { default: _, ...themeStateStyles } = this.themeStyles;
const { data: displayModelData } = this.displayModel;
let styles = {}; // merged styles
@ -477,6 +642,7 @@ export default abstract class Item implements IItem {
previous: previousStates,
current: this.states,
},
onfinish,
);
}
@ -489,6 +655,14 @@ export default abstract class Item implements IItem {
return keyShape?.getRenderBounds() || ({ center: [0, 0, 0] } as AABB);
}
/**
* Get the local bounding box for the keyShape.
* */
public getLocalKeyBBox(): AABB {
const { keyShape } = this.shapeMap;
return keyShape?.getLocalBounds() || ({ center: [0, 0, 0] } as AABB);
}
/**
* Get the rendering bouding box of the whole item.
* @returns item's rendering bounding box
@ -496,6 +670,67 @@ export default abstract class Item implements IItem {
public getBBox(): AABB {
return this.group.getRenderBounds();
}
/**
* Stop all the animations on the item.
*/
public stopAnimations() {
this.animations?.forEach((animation) => {
const timing = animation.effect.getTiming();
if (animation.playState !== 'running') return;
animation.currentTime =
Number(timing.duration) + Number(timing.delay || 0);
animation.cancel();
});
this.animations = [];
}
/**
* Animations' frame listemer.
*/
public animateFrameListener = throttle(
(e) => {
this.onframe?.(e);
},
16,
{
trailing: true,
leading: true,
},
);
/**
* Call render extension's onZoom to response the graph zooming.
* @param zoom
*/
public updateZoom(zoom) {
this.zoom = zoom;
this.renderExt.onZoom(this.shapeMap, zoom);
}
/** Destroy the item. */
public destroy() {
const func = () => {
this.group.destroy();
this.model = null;
this.displayModel = null;
this.destroyed = true;
};
// 1. stop animations, run buildOut animations
this.stopAnimations();
const { animates } = this.displayModel.data;
if (animates.buildOut?.length) {
this.animations = this.runWithAnimates(
animates,
'buildOut',
undefined,
func,
);
} else {
// 2. clear group and remove group
func();
}
}
}
/**

View File

@ -2,17 +2,23 @@ import { Group } from '@antv/g';
import { clone } from '@antv/util';
import { Point } from '../types/common';
import { NodeModel } from '../types';
import { DisplayMapper, State } from '../types/item';
import {
DisplayMapper,
State,
ZoomStrategy,
ZoomStrategyObj,
} from '../types/item';
import { NodeDisplayModel, NodeModelData } from '../types/node';
import { NodeStyleSet } from '../types/theme';
import { updateShapes } from '../util/shape';
import { animateShapes, getAnimatesExcludePosition } from '../util/animate';
import Item from './item';
import {
getCircleIntersectByPoint,
getEllipseIntersectByPoint,
getNearestPoint,
getRectIntersectByPoint,
} from 'util/point';
} from '../util/point';
interface IProps {
model: NodeModel;
@ -22,8 +28,14 @@ interface IProps {
stateMapper: {
[stateName: string]: DisplayMapper;
};
themeStyles: NodeStyleSet;
zoom?: number;
theme: {
styles: NodeStyleSet;
zoomStrategy: ZoomStrategyObj;
};
device?: any; // for 3d shapes
onframe?: Function;
onfinish?: Function;
}
export default class Node extends Item {
public type: 'node';
@ -33,20 +45,25 @@ export default class Node extends Item {
super(props);
this.type = 'node';
this.init(props);
this.draw(this.displayModel as NodeDisplayModel);
this.draw(
this.displayModel as NodeDisplayModel,
undefined,
undefined,
props.onfinish,
);
}
public draw(
displayModel: NodeDisplayModel,
diffData?: { previous: NodeModelData; current: NodeModelData },
diffState?: { previous: State[]; current: State[] },
onfinish: Function = () => {},
) {
const { group, renderExt, shapeMap: prevShapeMap, model } = this;
const { data } = displayModel;
const { x = 0, y = 0, z = 0 } = data;
group.setPosition(x as number, y as number, z as number);
renderExt.mergeStyles(displayModel);
this.group.setAttribute('data-item-type', 'node');
this.group.setAttribute('data-item-id', model.id);
renderExt.mergeStyles(displayModel);
const firstRendering = !this.shapeMap?.keyShape;
const shapeMap = renderExt.draw(
displayModel,
this.shapeMap,
@ -56,29 +73,103 @@ export default class Node extends Item {
// add shapes to group, and update shapeMap
this.shapeMap = updateShapes(prevShapeMap, shapeMap, group);
const { haloShape, labelShape, labelBackgroundShape } = this.shapeMap;
const { animates, disableAnimate, x = 0, y = 0, z = 0 } = displayModel.data;
if (firstRendering) {
// first rendering, move the group
group.style.x = x;
group.style.y = y;
group.style.z = z;
} else {
this.updatePosition(displayModel, diffData, onfinish);
}
const { haloShape, labelBackgroundShape } = this.shapeMap;
haloShape?.toBack();
labelShape?.toFront();
labelBackgroundShape?.toBack();
super.draw(displayModel, diffData, diffState);
super.draw(displayModel, diffData, diffState, onfinish);
this.anchorPointsCache = undefined;
renderExt.updateCache(this.shapeMap);
if (firstRendering) {
// update the transform
renderExt.onZoom(this.shapeMap, this.zoom);
}
// terminate previous animations
this.stopAnimations();
// handle shape's and group's animate
if (!disableAnimate && animates) {
const animatesExcludePosition = getAnimatesExcludePosition(animates);
this.animations = animateShapes(
animatesExcludePosition, // animates
renderExt.mergedStyles, // targetStylesMap
this.shapeMap, // shapeMap
group,
firstRendering ? 'buildIn' : 'update',
this.changedStates,
this.animateFrameListener,
() => onfinish(model.id),
);
}
}
public update(
model: NodeModel,
diffData?: { previous: NodeModelData; current: NodeModelData },
isReplace?: boolean,
themeStyles?: NodeStyleSet,
theme?: {
styles: NodeStyleSet;
zoomStrategy: ZoomStrategyObj;
},
onlyMove?: boolean,
onfinish?: Function,
) {
super.update(model, diffData, isReplace, themeStyles);
const { data } = this.displayModel;
const { x = 0, y = 0 } = data;
this.group.style.x = x;
this.group.style.y = y;
super.update(model, diffData, isReplace, theme, onlyMove, onfinish);
}
public clone(containerGroup: Group, onlyKeyShape?: boolean) {
/**
* Update the node's position,
* do not update other styles which leads to better performance than updating position by updateData.
*/
public updatePosition(
displayModel: NodeDisplayModel,
diffData?: { previous: NodeModelData; current: NodeModelData },
onfinish: Function = () => {},
) {
const { group } = this;
const { x = 0, y = 0, z = 0, animates, disableAnimate } = displayModel.data;
if (!disableAnimate && animates?.update) {
const groupAnimates = animates.update.filter(
({ shapeId, fields }) =>
(!shapeId || shapeId === 'group') &&
(fields.includes('x') || fields.includes('y')),
);
if (groupAnimates.length) {
animateShapes(
{ update: groupAnimates },
{ group: { x, y, z } } as any, // targetStylesMap
this.shapeMap, // shapeMap
group,
'update',
[],
this.animateFrameListener,
() => onfinish(displayModel.id),
);
}
return;
}
group.style.x = x;
group.style.y = y;
group.style.z = z;
}
public clone(
containerGroup: Group,
onlyKeyShape?: boolean,
disableAnimate?: boolean,
) {
if (onlyKeyShape) {
const clonedKeyShape = this.shapeMap.keyShape.cloneNode();
const { x, y } = this.group.attributes;
@ -88,20 +179,28 @@ export default class Node extends Item {
containerGroup.appendChild(clonedGroup);
return clonedGroup;
}
const clonedModel = clone(this.model);
clonedModel.data.disableAnimate = disableAnimate;
return new Node({
model: clone(this.model),
model: clonedModel,
renderExtensions: this.renderExtensions,
containerGroup,
mapper: this.mapper,
stateMapper: this.stateMapper,
themeStyles: clone(this.themeStyles),
zoom: this.zoom,
theme: {
styles: clone(this.themeStyles),
zoomStrategy: this.zoomStrategy,
},
});
}
public getAnchorPoint(point: Point) {
const { keyShape } = this.shapeMap;
const shapeType = keyShape.nodeName;
const { x, y, anchorPoints = [] } = this.model.data as NodeModelData;
const { x, y, z, anchorPoints = [] } = this.model.data as NodeModelData;
return { x, y, z };
let intersectPoint: Point | null;
switch (shapeType) {

View File

@ -52,8 +52,14 @@ export class DataController {
condition: ID[] | Function,
): EdgeModel[] | NodeModel[] | ComboModel[] {
const { graphCore } = this;
if (isString(condition) || isNumber(condition) || isArray(condition)) {
const ids = isArray(condition) ? condition : [condition];
const conditionType = typeof condition;
const conditionIsArray = isArray(condition);
if (
conditionType === 'string' ||
conditionType === 'number' ||
conditionIsArray
) {
const ids = conditionIsArray ? condition : [condition];
switch (type) {
case 'node':
return ids.map((id) =>
@ -67,7 +73,7 @@ export class DataController {
// TODO;
return;
}
} else if (isFunction(condition)) {
} else if (conditionType === 'function') {
const getDatas =
type === 'node' ? graphCore.getAllNodes : graphCore.getAllEdges;
if (type === 'combo') {

View File

@ -1,5 +1,6 @@
import { AABB, Canvas, DisplayObject, Group } from '@antv/g';
import { GraphChange, ID } from '@antv/graphlib';
import { debounce, isArray, isObject, throttle } from '@antv/util';
import registry from '../../stdlib';
import {
ComboModel,
@ -23,7 +24,12 @@ import Combo from '../../item/combo';
import { upsertShape } from '../../util/shape';
import { getExtension } from '../../util/extension';
import { upsertTransientItem } from '../../util/item';
import { ITEM_TYPE, ShapeStyle, SHAPE_TYPE } from '../../types/item';
import {
ITEM_TYPE,
ShapeStyle,
SHAPE_TYPE,
ZoomStrategyObj,
} from '../../types/item';
import {
ThemeSpecification,
NodeThemeSpecifications,
@ -31,8 +37,9 @@ import {
NodeStyleSet,
EdgeStyleSet,
} from '../../types/theme';
import { isArray, isObject } from '@antv/util';
import { DirectionalLight, AmbientLight } from '@antv/g-plugin-3d';
import { ViewportChangeHookParams } from '../../types/hook';
import { formatZoomStrategy } from '../../util/zoom';
/**
* Manages and stores the node / edge / combo items.
@ -115,6 +122,7 @@ export class ItemController {
this.onItemVisibilityChange.bind(this),
);
this.graph.hooks.transientupdate.tap(this.onTransientUpdate.bind(this));
this.graph.hooks.viewportchange.tap(this.onViewportChange.bind(this));
}
/**
@ -208,8 +216,9 @@ export class ItemController {
changes: GraphChange<NodeModelData, EdgeModelData>[];
graphCore: GraphCore;
theme: ThemeSpecification;
action?: 'updateNodePosition';
}) {
const { changes, graphCore, theme = {} } = param;
const { changes, graphCore, theme = {}, action } = param;
const groupedChanges = {
NodeRemoved: [],
EdgeRemoved: [],
@ -275,33 +284,54 @@ export class ItemController {
});
const { dataTypeField: nodeDataTypeField } = nodeTheme;
const edgeToUpdate = {};
const updateEdges = throttle(
() => {
Object.keys(edgeToUpdate).forEach((id) => {
const item = itemMap[id] as Edge;
if (item && !item.destroyed) item.forceUpdate();
});
},
16,
{
leading: true,
trailing: true,
},
);
Object.keys(nodeUpdate).forEach((id) => {
const { isReplace, previous, current } = nodeUpdate[id];
// update the theme if the dataType value is changed
let themeStyles;
let itemTheme;
if (
nodeDataTypeField &&
previous[nodeDataTypeField] !== current[nodeDataTypeField]
) {
themeStyles = getThemeStyles(
itemTheme = getItemTheme(
this.nodeDataTypeSet,
nodeDataTypeField,
current[nodeDataTypeField],
nodeTheme,
);
}
const item = itemMap[id];
const node = itemMap[id] as Node;
const innerModel = graphCore.getNode(id);
item.update(innerModel, { previous, current }, isReplace, themeStyles);
const relatedEdgeInnerModels = graphCore.getRelatedEdges(id);
relatedEdgeInnerModels.forEach(
(edge) => (edgeToUpdate[edge.id] = edge),
node.onframe = updateEdges;
node.update(
innerModel,
{ previous, current },
isReplace,
itemTheme,
action === 'updateNodePosition',
// call after updating finished
() => {
node.onframe = undefined;
},
);
const relatedEdgeInnerModels = graphCore.getRelatedEdges(id);
relatedEdgeInnerModels.forEach((edge) => {
edgeToUpdate[edge.id] = edge;
});
});
Object.keys(edgeToUpdate).forEach((id) => {
const item = itemMap[id] as Edge;
item.forceUpdate();
});
updateEdges();
}
// === 6. update edges' data ===
@ -326,9 +356,9 @@ export class ItemController {
Object.keys(edgeUpdate).forEach((id) => {
const { isReplace, current, previous } = edgeUpdate[id];
// update the theme if the dataType value is changed
let themeStyles;
let itemTheme;
if (previous[edgeDataTypeField] !== current[edgeDataTypeField]) {
themeStyles = getThemeStyles(
itemTheme = getItemTheme(
this.edgeDataTypeSet,
edgeDataTypeField,
current[edgeDataTypeField],
@ -337,7 +367,7 @@ export class ItemController {
}
const item = itemMap[id];
const innerModel = graphCore.getEdge(id);
item.update(innerModel, { current, previous }, isReplace, themeStyles);
item.update(innerModel, { current, previous }, isReplace, itemTheme);
});
}
@ -392,8 +422,12 @@ export class ItemController {
});
}
private onItemVisibilityChange(param: { ids: ID[]; value: boolean }) {
const { ids, value } = param;
private onItemVisibilityChange(param: {
ids: ID[];
value: boolean;
animate?: boolean;
}) {
const { ids, value, animate = true } = param;
ids.forEach((id) => {
const item = this.itemMap[id];
if (!item) {
@ -403,13 +437,28 @@ export class ItemController {
return;
}
if (value) {
item.show();
item.show(animate);
} else {
item.hide();
item.hide(animate);
}
});
}
private onViewportChange = debounce(
({ transform, effectTiming }: ViewportChangeHookParams) => {
const { zoom } = transform;
if (zoom) {
const zoomRatio = this.graph.getZoom();
Object.values(this.itemMap).forEach((item) =>
item.updateZoom(zoomRatio),
);
this.zoom = zoomRatio;
}
},
500,
false,
);
private onTransientUpdate(param: {
type: ITEM_TYPE | SHAPE_TYPE;
id: ID;
@ -493,7 +542,7 @@ export class ItemController {
return;
}
const { shape } = upsertShape(type, String(id), style, transientObjectMap);
const shape = upsertShape(type, String(id), style, transientObjectMap);
shape.style.pointerEvents = capture ? 'auto' : 'none';
canvas.getRoot().appendChild(shape);
}
@ -511,11 +560,12 @@ export class ItemController {
) {
const { nodeExtensions, nodeGroup, nodeDataTypeSet, graph } = this;
const { dataTypeField } = nodeTheme;
const zoom = graph.getZoom();
models.forEach((node) => {
// get the base styles from theme
let dataType;
if (dataTypeField) dataType = node.data[dataTypeField] as string;
const themeStyle = getThemeStyles(
const itemTheme = getItemTheme(
nodeDataTypeSet,
dataTypeField,
dataType,
@ -528,7 +578,11 @@ export class ItemController {
containerGroup: nodeGroup,
mapper: this.nodeMapper,
stateMapper: this.nodeStateMapper,
themeStyles: themeStyle as NodeStyleSet,
zoom,
theme: itemTheme as {
styles: NodeStyleSet;
zoomStrategy: ZoomStrategyObj;
},
device:
graph.rendererType === 'webgl-3d'
? // TODO: G type
@ -546,8 +600,9 @@ export class ItemController {
models: EdgeModel[],
edgeTheme: EdgeThemeSpecifications = {},
) {
const { edgeExtensions, edgeGroup, itemMap, edgeDataTypeSet } = this;
const { edgeExtensions, edgeGroup, itemMap, edgeDataTypeSet, graph } = this;
const { dataTypeField } = edgeTheme;
const zoom = graph.getZoom();
models.forEach((edge) => {
const { source, target, id } = edge;
const sourceItem = itemMap[source] as Node;
@ -565,7 +620,7 @@ export class ItemController {
// get the base styles from theme
let dataType;
if (dataTypeField) dataType = edge.data[dataTypeField] as string;
const themeStyle = getThemeStyles(
const itemTheme = getItemTheme(
edgeDataTypeSet,
dataTypeField,
dataType,
@ -580,7 +635,11 @@ export class ItemController {
stateMapper: this.edgeStateMapper,
sourceItem,
targetItem,
themeStyles: themeStyle as EdgeStyleSet,
zoom,
theme: itemTheme as {
styles: EdgeStyleSet;
zoomStrategy: ZoomStrategyObj;
},
});
});
}
@ -649,18 +708,23 @@ export class ItemController {
}
}
const getThemeStyles = (
const getItemTheme = (
dataTypeSet: Set<string>,
dataTypeField: string,
dataType: string,
itemTheme: NodeThemeSpecifications | EdgeThemeSpecifications,
): NodeStyleSet | EdgeStyleSet => {
const { styles: themeStyles } = itemTheme;
): {
styles: NodeStyleSet | EdgeStyleSet;
zoomStrategy: ZoomStrategyObj;
} => {
const { styles: themeStyles, zoomStrategy } = itemTheme;
const formattedZoomStrategy = formatZoomStrategy(zoomStrategy);
if (!dataTypeField) {
// dataType field is not assigned
return isArray(themeStyles)
const styles = isArray(themeStyles)
? themeStyles[0]
: Object.values(themeStyles)[0];
return { styles, zoomStrategy: formattedZoomStrategy };
}
dataTypeSet.add(dataType as string);
let themeStyle;
@ -671,5 +735,8 @@ const getThemeStyles = (
} else if (isObject(themeStyles)) {
themeStyle = themeStyles[dataType] || themeStyles.others;
}
return themeStyle;
return {
styles: themeStyle,
zoomStrategy: formattedZoomStrategy,
};
};

View File

@ -175,15 +175,7 @@ export class LayoutController {
}
private updateNodesPosition(positions: LayoutMapping) {
positions.nodes.forEach((node) => {
this.graph.updateData('node', {
id: node.id,
data: {
x: node.data.x,
y: node.data.y,
},
});
});
this.graph.updateNodePosition(positions.nodes);
}
/**

View File

@ -147,7 +147,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
return;
}
this.container = containerDOM;
let size = [width, height];
const size = [width, height];
if (size[0] === undefined) {
size[0] = containerDOM.scrollWidth;
}
@ -257,7 +257,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
* Update the specs(configurations).
*/
public updateSpecification(spec: Specification<B, T>) {
// TODO
return Object.assign(this.specification, spec);
}
/**
@ -794,6 +794,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
const { graphCore } = this.dataController;
const { specification } = this.themeController;
graphCore.once('changed', (event) => {
if (!event.changes.length) return;
this.hooks.itemchange.emit({
type: itemType,
changes: graphCore.reduceChanges(event.changes),
@ -832,6 +833,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
itemType === 'edge' ? userGraphCore.getEdge : userGraphCore.getNode; // TODO: combo
data[`${itemType}s`] = idArr.map((id) => getItem.bind(userGraphCore)(id));
graphCore.once('changed', (event) => {
if (!event.changes.length) return;
this.hooks.itemchange.emit({
type: itemType,
changes: event.changes,
@ -847,9 +849,9 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
}
/**
* Update one or more node/edge/combo data on the graph.
* @param {Item} item item or id
* @param {EdgeConfig | NodeConfig} cfg incremental updated configs
* @param {boolean} stack true
* @param {ITEM_TYPE} itemType 'node' | 'edge' | 'combo'
* @param models new configurations for every node/edge/combo, which has id field to indicate the specific item
* @param {boolean} stack whether push this operation into graph's stack, true by default
* @group Data
*/
public updateData(
@ -901,17 +903,66 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
);
return isArray(models) ? dataList : dataList[0];
}
/**
* Update one or more nodes' positions,
* do not update other styles which leads to better performance than updating positions by updateData.
* @param models new configurations with x and y for every node, which has id field to indicate the specific item
* @param {boolean} stack whether push this operation into graph's stack, true by default
* @group Data
*/
public updateNodePosition(
models:
| Partial<NodeUserModel>
| Partial<
ComboUserModel | Partial<NodeUserModel>[] | Partial<ComboUserModel>[]
>,
stack?: boolean,
) {
const modelArr = isArray(models) ? models : [models];
const { graphCore } = this.dataController;
const { specification } = this.themeController;
graphCore.once('changed', (event) => {
if (!event.changes.length) return;
this.hooks.itemchange.emit({
type: 'node',
changes: event.changes,
graphCore,
theme: specification,
action: 'updateNodePosition',
});
this.emit('afteritemchange', {
type: 'node',
action: 'updateNodePosition',
models,
});
});
this.hooks.datachange.emit({
data: {
nodes: modelArr as NodeUserModel[],
edges: [],
},
type: 'update',
});
const dataList = this.dataController.findData(
'node',
modelArr.map((model) => model.id),
);
return isArray(models) ? dataList : dataList[0];
}
/**
* Show the item(s).
* @param item the item to be shown
* @returns
* @group Item
*/
public showItem(ids: ID | ID[]) {
public showItem(ids: ID | ID[], disableAniamte?: boolean) {
const idArr = isArray(ids) ? ids : [ids];
this.hooks.itemvisibilitychange.emit({
ids: idArr as ID[],
value: true,
animate: !disableAniamte,
});
}
/**
@ -920,11 +971,12 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
* @returns
* @group Item
*/
public hideItem(ids: ID | ID[]) {
public hideItem(ids: ID | ID[], disableAniamte?: boolean) {
const idArr = isArray(ids) ? ids : [ids];
this.hooks.itemvisibilitychange.emit({
ids: idArr as ID[],
value: false,
animate: !disableAniamte,
});
}
/**

View File

@ -72,6 +72,7 @@ export default class ClickSelect extends Behavior {
getEvents = () => {
return {
'node:click': this.onClick,
'edge:click': this.onClick,
'canvas:click': this.onCanvasClick,
};
};

View File

@ -1,6 +1,6 @@
import { ID, IG6GraphEvent } from 'types';
import { Behavior } from '../../types/behavior';
import { Point } from 'types/common';
import { Point } from '../../types/common';
const VALID_TRIGGERS = ['drag', 'directionKeys'];
export interface DragCanvasOptions {
@ -125,7 +125,7 @@ export default class DragCanvas extends Behavior {
.getAllEdgesData()
.map((edge) => edge.id)
.filter((id) => graph.getItemVisible(id) === true);
graph.hideItem(this.hiddenEdgeIds);
graph.hideItem(this.hiddenEdgeIds, true);
this.hiddenNodeIds = graph
.getAllNodesData()
.map((node) => node.id)
@ -136,7 +136,7 @@ export default class DragCanvas extends Behavior {
onlyDrawKeyShape: true,
});
});
graph.hideItem(this.hiddenNodeIds);
graph.hideItem(this.hiddenNodeIds, true);
}
}
@ -185,8 +185,8 @@ export default class DragCanvas extends Behavior {
const { graph } = this;
const { client } = event;
const { eventName, direction } = this.options;
let diffX = client.x - this.pointerDownAt.x;
let diffY = client.y - this.pointerDownAt.y;
const diffX = client.x - this.pointerDownAt.x;
const diffY = client.y - this.pointerDownAt.y;
if (direction === 'x' && !diffX) return;
if (direction === 'y' && !diffY) return;
if (direction === 'both' && !diffX && !diffY) return;
@ -215,13 +215,13 @@ export default class DragCanvas extends Behavior {
const { graph } = this;
if (this.options.enableOptimize) {
if (this.hiddenEdgeIds) {
graph.showItem(this.hiddenEdgeIds);
graph.showItem(this.hiddenEdgeIds, true);
}
if (this.hiddenNodeIds) {
this.hiddenNodeIds.forEach((id) => {
this.graph.drawTransient('node', id, { action: 'remove' });
});
graph.showItem(this.hiddenNodeIds);
graph.showItem(this.hiddenNodeIds, true);
}
}
}

View File

@ -1,5 +1,5 @@
import { ID } from '@antv/graphlib';
import { debounce, uniq } from '@antv/util';
import { throttle, uniq } from '@antv/util';
import { EdgeModel } from '../../types';
import { Behavior } from '../../types/behavior';
import { IG6GraphEvent } from '../../types/event';
@ -37,10 +37,10 @@ export interface DragNodeOptions {
[key: string]: unknown;
};
/**
* The time in milliseconds to debounce moving. Useful to avoid the frequent calculation.
* The time in milliseconds to throttle moving. Useful to avoid the frequent calculation.
* Defaults to 0.
*/
debounce?: number;
throttle?: number;
/**
* Whether to hide the related edges to avoid calculation while dragging nodes.
* Ignored when enableTransient or enableDelegate is true.
@ -72,7 +72,7 @@ const DEFAULT_OPTIONS: Required<DragNodeOptions> = {
strokeOpacity: 0.9,
lineDash: [5, 5],
},
debounce: 0,
throttle: 16,
hideRelatedEdges: false,
selectedState: 'selected',
eventName: '',
@ -167,7 +167,10 @@ export class DragNode extends Behavior {
// Hide related edge.
if (this.options.hideRelatedEdges && !enableTransient) {
this.hiddenEdges = this.getRelatedEdges(selectedNodeIds);
this.graph.hideItem(this.hiddenEdges.map((edge) => edge.id));
this.graph.hideItem(
this.hiddenEdges.map((edge) => edge.id),
true,
);
}
// Draw transient nodes and edges.
@ -182,15 +185,22 @@ export class DragNode extends Behavior {
});
// Hide original edges and nodes. They will be restored when pointerup.
this.graph.hideItem(selectedNodeIds);
this.graph.hideItem(this.hiddenEdges.map((edge) => edge.id));
this.graph.hideItem(selectedNodeIds, true);
this.graph.hideItem(
this.hiddenEdges.map((edge) => edge.id),
true,
);
}
// Debounce moving.
if (this.options.debounce > 0) {
this.debouncedMoveNodes = debounce(this.moveNodes, this.options.debounce);
// Throttle moving.
if (this.options.throttle > 0) {
this.throttledMoveNodes = throttle(
this.moveNodes,
this.options.throttle,
{ leading: true, trailing: true },
);
} else {
this.debouncedMoveNodes = this.moveNodes;
this.throttledMoveNodes = this.moveNodes;
}
// @ts-ignore FIXME: Type
@ -216,7 +226,7 @@ export class DragNode extends Behavior {
} else {
const enableTransient =
this.options.enableTransient && this.graph.rendererType !== 'webgl-3d';
this.debouncedMoveNodes(deltaX, deltaY, enableTransient);
this.throttledMoveNodes(deltaX, deltaY, enableTransient);
}
}
@ -245,17 +255,17 @@ export class DragNode extends Behavior {
this.graph.drawTransient('edge', edge.id, {});
});
} else {
this.graph.updateData('node', positionChanges);
this.graph.updateNodePosition(positionChanges);
}
}
public debouncedMoveNodes(
public throttledMoveNodes: Function = (
deltaX: number,
deltaY: number,
transient: boolean,
) {
) => {
// Should be overrided when drag start.
}
};
public moveDelegate(deltaX: number, deltaY: number) {
const x1 = Math.min(
@ -298,13 +308,19 @@ export class DragNode extends Behavior {
public restoreHiddenItems() {
if (this.hiddenEdges.length) {
this.graph.showItem(this.hiddenEdges.map((edge) => edge.id));
this.graph.showItem(
this.hiddenEdges.map((edge) => edge.id),
true,
);
this.hiddenEdges = [];
}
const enableTransient =
this.options.enableTransient && this.graph.rendererType !== 'webgl-3d';
if (enableTransient) {
this.graph.showItem(this.originPositions.map((position) => position.id));
this.graph.showItem(
this.originPositions.map((position) => position.id),
true,
);
}
}
@ -360,7 +376,7 @@ export class DragNode extends Behavior {
const positionChanges = this.originPositions.map(({ id, x, y }) => {
return { id, data: { x, y } };
});
this.graph.updateData('node', positionChanges);
this.graph.updateNodePosition(positionChanges);
}
this.originPositions = [];

View File

@ -1,6 +1,5 @@
import { ID, IG6GraphEvent } from 'types';
import { Behavior } from '../../types/behavior';
import { Point } from 'types/common';
const VALID_TRIGGERS = ['wheel', 'upDownKeys'];
export interface ZoomCanvasOptions {
@ -49,7 +48,7 @@ export interface ZoomCanvasOptions {
}
const DEFAULT_OPTIONS: Required<ZoomCanvasOptions> = {
enableOptimize: true,
enableOptimize: false,
zoomOnItems: false,
sensitivity: 1,
trigger: 'wheel',

View File

@ -17,19 +17,27 @@ import {
SHAPE_TYPE,
ShapeStyle,
State,
ZoomStrategyObj,
} from '../../../types/item';
import {
LOCAL_BOUNDS_DIRTY_FLAG_KEY,
formatPadding,
isStyleAffectBBox,
mergeStyles,
upsertShape,
} from '../../../util/shape';
import { DEFAULT_ANIMATE_CFG, fadeIn, fadeOut } from '../../../util/animate';
import { getWordWrapWidthByEnds } from '../../../util/text';
import { AnimateCfg } from '../../../types/animate';
import { getZoomLevel } from '../../../util/zoom';
export abstract class BaseEdge {
type: string;
defaultStyles: EdgeShapeStyles = {};
themeStyles: EdgeShapeStyles;
mergedStyles: EdgeShapeStyles;
sourcePoint: Point;
targetPoint: Point;
zoomStrategy?: ZoomStrategyObj;
labelPosition: {
x: number;
y: number;
@ -39,11 +47,48 @@ export abstract class BaseEdge {
boundsCache: {
labelShapeGeometry?: AABB;
labelBackgroundShapeGeometry?: AABB;
labelShapeTransform?: string;
labelBackgroundShapeTransform?: string;
};
// cache the zoom level infomations
private zoomCache: {
// the id of shapes which are hidden by zoom changing.
hiddenShape: { [shapeId: string]: boolean };
// timeout timer for scaling shapes with balanceRatio, simulates debounce in function.
balanceTimer: NodeJS.Timeout;
// the ratio to scale the size of shapes whose visual size should be kept, e.g. label and badges.
balanceRatio: number;
// last responsed zoom ratio.
zoom: number;
// last responsed zoom level where the zoom ratio at. Zoom ratio 1 at level 0.
zoomLevel: number;
// shape ids in different zoom levels.
levelShapes: {
[level: string]: string[];
};
// wordWrapWidth of labelShape according to the maxWidth
wordWrapWidth: number;
// animate configurations for zoom level changing
animateConfig: AnimateCfg;
} = {
hiddenShape: {},
balanceRatio: 1,
zoom: 1,
zoomLevel: 0,
balanceTimer: undefined,
levelShapes: {},
wordWrapWidth: 50,
animateConfig: DEFAULT_ANIMATE_CFG.zoom,
};
constructor(props) {
const { themeStyles } = props;
const { themeStyles, zoomStrategy } = props;
if (themeStyles) this.themeStyles = themeStyles;
this.zoomStrategy = zoomStrategy;
this.boundsCache = {};
this.zoomCache.animateConfig = {
...DEFAULT_ANIMATE_CFG.zoom,
...zoomStrategy?.animateCfg,
};
}
public mergeStyles(model: EdgeDisplayModel) {
this.mergedStyles = this.getMergedStyles(model);
@ -61,8 +106,56 @@ export abstract class BaseEdge {
);
}
});
return mergeStyles([this.themeStyles, this.defaultStyles, dataStyles]);
const merged = mergeStyles([
this.themeStyles,
this.defaultStyles,
dataStyles,
]) as EdgeShapeStyles;
const padding = merged.labelBackgroundShape?.padding;
if (padding) {
merged.labelBackgroundShape.padding = formatPadding(
padding,
DEFAULT_LABEL_BG_PADDING,
);
}
return merged;
}
/**
* Call it after calling draw function to update cache about bounds and zoom levels.
*/
public updateCache(shapeMap) {
['labelShape', 'labelBackgroundShape'].forEach((id) => {
const shape = shapeMap[id];
if (shape?.getAttribute(LOCAL_BOUNDS_DIRTY_FLAG_KEY)) {
this.boundsCache[`${id}Geometry`] = shape.getGeometryBounds();
this.boundsCache[`${id}Transform`] = shape.style.transform;
shape.setAttribute(LOCAL_BOUNDS_DIRTY_FLAG_KEY, false);
}
});
const { levelShapes, zoom } = this.zoomCache;
Object.keys(shapeMap).forEach((shapeId) => {
const { showLevel } = shapeMap[shapeId].attributes;
if (showLevel !== undefined) {
levelShapes[showLevel] = levelShapes[showLevel] || [];
levelShapes[showLevel].push(shapeId);
}
});
const { maxWidth = '60%' } = this.mergedStyles.labelShape || {};
this.zoomCache.wordWrapWidth = getWordWrapWidthByEnds(
[this.sourcePoint, this.targetPoint],
maxWidth,
1,
);
this.zoomCache.zoom = 1;
this.zoomCache.zoomLevel = 0;
if (zoom !== 1) this.onZoom(shapeMap, zoom);
}
abstract draw(
model: EdgeDisplayModel,
sourcePoint: Point,
@ -104,6 +197,7 @@ export abstract class BaseEdge {
offsetX: propsOffsetX,
offsetY: propsOffsetY,
autoRotate = true,
maxWidth,
...otherStyle
} = shapeStyle;
@ -177,22 +271,19 @@ export abstract class BaseEdge {
...positionStyle,
isRevert,
};
const wordWrapWidth = getWordWrapWidthByEnds(
[this.sourcePoint, this.targetPoint],
maxWidth,
this.zoomCache.zoom,
);
const style = {
...this.defaultStyles.labelShape,
textAlign: positionPreset.textAlign,
wordWrapWidth,
...positionStyle,
...otherStyle,
};
const { shape, updateStyles } = this.upsertShape(
'text',
'labelShape',
style,
shapeMap,
);
if (isStyleAffectBBox('text', updateStyles)) {
this.boundsCache.labelShapeGeometry = shape.getGeometryBounds();
}
return shape;
return this.upsertShape('text', 'labelShape', style, shapeMap, model);
}
public drawLabelBackgroundShape(
@ -210,17 +301,17 @@ export abstract class BaseEdge {
const textBBox =
this.boundsCache.labelShapeGeometry || labelShape.getGeometryBounds();
const { x, y, transform, isRevert } = this.labelPosition;
const { padding: propsPadding, ...backgroundStyle } = labelBackgroundShape;
const padding = formatPadding(propsPadding, DEFAULT_LABEL_BG_PADDING);
const { padding, ...backgroundStyle } = labelBackgroundShape;
const { balanceRatio = 1 } = this.zoomCache;
const textWidth = textBBox.max[0] - textBBox.min[0];
const textHeight = textBBox.max[1] - textBBox.min[1];
const bgStyle = {
fill: '#fff',
...backgroundStyle,
x: textBBox.min[0] - padding[3] + x,
y: textBBox.min[1] - padding[0] + y,
y: textBBox.min[1] - padding[0] / balanceRatio + y,
width: textWidth + padding[1] + padding[3],
height: textHeight + padding[0] + padding[2],
height: textHeight + (padding[0] + padding[2]) / balanceRatio,
transform: transform,
};
if (labelShapeStyle.position === 'start') {
@ -245,16 +336,13 @@ export abstract class BaseEdge {
}`;
}
const { shape, updateStyles } = this.upsertShape(
return this.upsertShape(
'rect',
'labelBackgroundShape',
bgStyle,
shapeMap,
model,
);
if (isStyleAffectBBox('rect', updateStyles)) {
this.boundsCache.labelBackgroundShapeGeometry = shape.getGeometryBounds();
}
return shape;
}
public drawIconShape(
@ -336,7 +424,8 @@ export abstract class BaseEdge {
'iconShape',
shapeStyle as GShapeStyle,
shapeMap,
).shape;
model,
);
}
public drawHaloShape(
@ -357,7 +446,99 @@ export abstract class BaseEdge {
isBillboard: true,
},
shapeMap,
).shape;
model,
);
}
/**
* The listener for graph zooming.
* 1. show / hide some shapes while zoom level changed;
* 2. change the shapes' sizes to make them have same visual size while zooming, e.g. labelShape, labelBackgroundShape.
* @param shapeMap
* @param zoom
*/
public onZoom = (shapeMap: EdgeShapeMap, zoom: number) => {
// balance the size for label, badges
this.balanceShapeSize(shapeMap, zoom);
// zoomLevel changed
if (!this.zoomStrategy) return;
const { levels } = this.zoomStrategy;
const {
levelShapes,
hiddenShape,
animateConfig,
zoomLevel: previousLevel,
} = this.zoomCache;
// last zoom ratio responsed by zoom changing, which might not equal to zoom.previous in props since the function is debounced.
const currentLevel = getZoomLevel(levels, zoom);
if (currentLevel < previousLevel) {
// zoomLevel changed, from higher to lower, hide something
levelShapes[currentLevel + 1]?.forEach((id) =>
fadeOut(id, shapeMap[id], hiddenShape, animateConfig),
);
} else if (currentLevel > previousLevel) {
// zoomLevel changed, from lower to higher, show something
levelShapes[String(currentLevel)]?.forEach((id) =>
fadeIn(
id,
shapeMap[id],
this.mergedStyles[id] ||
this.mergedStyles[id.replace('Background', '')],
hiddenShape,
animateConfig,
),
);
}
this.zoomCache.zoom = zoom;
this.zoomCache.zoomLevel = currentLevel;
};
/**
* Update the shapes' sizes e.g. labelShape, labelBackgroundShape, to keep the visual size while zooming.
* @param shapeMap
* @param zoom
* @returns
*/
private balanceShapeSize(shapeMap, zoom) {
const { labelShape, labelBackgroundShape } = shapeMap;
const balanceRatio = 1 / zoom || 1;
this.zoomCache.balanceRatio = balanceRatio;
const { labelShape: labelStyle } = this.mergedStyles;
const { position = 'bottom' } = labelStyle;
if (!labelShape) return;
if (position === 'bottom') labelShape.style.transformOrigin = '0';
else labelShape.style.transformOrigin = '';
const oriTransform = this.boundsCache.labelShapeTransform;
labelShape.style.transform = `${oriTransform} scale(${balanceRatio}, ${balanceRatio})`;
const wordWrapWidth = this.zoomCache.wordWrapWidth * zoom;
labelShape.style.wordWrapWidth = wordWrapWidth;
if (!labelBackgroundShape) return;
const oriBgTransform = this.boundsCache.labelBackgroundShapeTransform;
labelBackgroundShape.style.transform = `${oriBgTransform} scale(1, ${balanceRatio})`;
}
/**
* Update the source point { x, y } for the edge. Called in item's draw func.
* @param point
*/
public setSourcePoint(point: Point) {
this.sourcePoint = point;
}
/**
* Update the target point { x, y } for the edge. Called in item's draw func.
* @param point
*/
public setTargetPoint(point: Point) {
this.targetPoint = point;
}
public upsertShape(
@ -365,10 +546,8 @@ export abstract class BaseEdge {
id: string,
style: ShapeStyle,
shapeMap: { [shapeId: string]: DisplayObject },
): {
updateStyles: ShapeStyle;
shape: DisplayObject;
} {
return upsertShape(type, id, style as GShapeStyle, shapeMap);
model: EdgeDisplayModel,
): DisplayObject {
return upsertShape(type, id, style as GShapeStyle, shapeMap, model);
}
}

View File

@ -1,4 +1,3 @@
import { isStyleAffectBBox } from 'util/shape';
import { Point } from '../../../types/common';
import {
EdgeDisplayModel,
@ -93,6 +92,7 @@ export class LineEdge extends BaseEdge {
isBillboard: true,
},
shapeMap,
).shape;
model,
);
}
}

View File

@ -1,9 +1,5 @@
import { AABB, DisplayObject, ImageStyleProps, TextStyleProps } from '@antv/g';
import {
DEFAULT_LABEL_BG_PADDING,
OTHER_SHAPES_FIELD_NAME,
RESERVED_SHAPE_IDS,
} from '../../../constant';
import { AABB, DisplayObject } from '@antv/g';
import { OTHER_SHAPES_FIELD_NAME, RESERVED_SHAPE_IDS } from '../../../constant';
import { NodeDisplayModel } from '../../../types';
import {
GShapeStyle,
@ -11,6 +7,7 @@ import {
SHAPE_TYPE_3D,
ShapeStyle,
State,
ZoomStrategyObj,
} from '../../../types/item';
import {
NodeModelData,
@ -18,25 +15,66 @@ import {
NodeShapeStyles,
} from '../../../types/node';
import {
LOCAL_BOUNDS_DIRTY_FLAG_KEY,
formatPadding,
isStyleAffectBBox,
mergeStyles,
upsertShape,
} from '../../../util/shape';
import { getWordWrapWidthByBox } from '../../../util/text';
import { DEFAULT_ANIMATE_CFG, fadeIn, fadeOut } from '../../../util/animate';
import { getZoomLevel } from '../../../util/zoom';
import { AnimateCfg } from '../../../types/animate';
export abstract class BaseNode {
type: string;
defaultStyles: NodeShapeStyles;
themeStyles: NodeShapeStyles;
mergedStyles: NodeShapeStyles;
zoomStrategy?: ZoomStrategyObj;
boundsCache: {
keyShapeLocal?: AABB;
labelShapeLocal?: AABB;
};
// cache the zoom level infomations
private zoomCache: {
// the id of shapes which are hidden by zoom changing.
hiddenShape: { [shapeId: string]: boolean };
// timeout timer for scaling shapes with to keep the visual size, simulates debounce in function.
balanceTimer: NodeJS.Timeout;
// the ratio to scale the shapes (e.g. labelShape, labelBackgroundShape) to keep to visual size while zooming.
balanceRatio: number;
// last responsed zoom ratio.
zoom: number;
// last responsed zoom level where the zoom ratio at. Zoom ratio 1 at level 0.
zoomLevel: number;
// shape ids in different zoom levels.
levelShapes: {
[level: string]: string[];
};
// wordWrapWidth of labelShape according to the maxWidth
wordWrapWidth: number;
// animate configurations for zoom level changing
animateConfig: AnimateCfg;
} = {
hiddenShape: {},
zoom: 1,
zoomLevel: 0,
balanceTimer: undefined,
balanceRatio: 1,
levelShapes: {},
wordWrapWidth: 32,
animateConfig: DEFAULT_ANIMATE_CFG.zoom,
};
constructor(props) {
const { themeStyles } = props;
const { themeStyles, zoomStrategy } = props;
if (themeStyles) this.themeStyles = themeStyles;
this.zoomStrategy = zoomStrategy;
this.boundsCache = {};
this.zoomCache.animateConfig = {
...DEFAULT_ANIMATE_CFG.zoom,
...zoomStrategy?.animateCfg,
};
}
public mergeStyles(model: NodeDisplayModel) {
this.mergedStyles = this.getMergedStyles(model);
@ -46,11 +84,21 @@ export abstract class BaseNode {
const dataStyles = {} as NodeShapeStyles;
Object.keys(data).forEach((fieldName) => {
if (RESERVED_SHAPE_IDS.includes(fieldName)) {
if (fieldName === 'BadgeShapes') {
Object.keys(data[fieldName]).forEach(
(badgeShapeId) =>
(dataStyles[badgeShapeId] = data[fieldName][badgeShapeId]),
);
if (fieldName === 'badgeShapes') {
Object.keys(data[fieldName]).forEach((idx) => {
const { position } = data[fieldName][idx];
dataStyles[`${position}BadgeShape`] = {
...data[fieldName][idx],
tag: 'badgeShape',
};
});
} else if (fieldName === 'anchorShapes') {
Object.keys(data[fieldName]).forEach((idx) => {
dataStyles[`anchorShape${idx}`] = {
...data[fieldName][idx],
tag: 'anchorShape',
};
});
} else {
dataStyles[fieldName] = data[fieldName] as ShapeStyle;
}
@ -61,7 +109,44 @@ export abstract class BaseNode {
);
}
});
return mergeStyles([this.themeStyles, this.defaultStyles, dataStyles]);
const merged = mergeStyles([
this.themeStyles,
this.defaultStyles,
dataStyles,
]) as NodeShapeStyles;
const padding = merged.labelBackgroundShape?.padding;
if (padding) {
merged.labelBackgroundShape.padding = formatPadding(padding);
}
return merged;
}
/**
* Call it after calling draw function to update cache about bounds and zoom levels.
*/
public updateCache(shapeMap) {
['keyShape', 'labelShape'].forEach((id) => {
const shape = shapeMap[id];
if (shape?.getAttribute(LOCAL_BOUNDS_DIRTY_FLAG_KEY)) {
this.boundsCache[`${id}Local`] = shape.getLocalBounds();
shape.setAttribute(LOCAL_BOUNDS_DIRTY_FLAG_KEY, false);
}
});
const { levelShapes } = this.zoomCache;
Object.keys(shapeMap).forEach((shapeId) => {
const { showLevel } = shapeMap[shapeId].attributes;
if (showLevel !== undefined) {
levelShapes[showLevel] = levelShapes[showLevel] || [];
levelShapes[showLevel].push(shapeId);
}
});
const { maxWidth = '200%' } = this.mergedStyles.labelShape || {};
this.zoomCache.wordWrapWidth = getWordWrapWidthByBox(
this.boundsCache.keyShapeLocal,
maxWidth,
1,
);
}
abstract draw(
model: NodeDisplayModel,
@ -100,20 +185,27 @@ export abstract class BaseNode {
model: NodeDisplayModel,
shapeMap: NodeShapeMap,
diffData?: { previous: NodeModelData; current: NodeModelData },
diffState?: { oldState: State[]; newState: State[] },
diffState?: { previous: State[]; current: State[] },
): DisplayObject {
const { keyShape } = shapeMap;
this.boundsCache.keyShapeLocal =
this.boundsCache.keyShapeLocal || keyShape.getLocalBounds();
const keyShapeBox = this.boundsCache.keyShapeLocal;
const { labelShape: shapeStyle } = this.mergedStyles;
const {
position,
offsetX: propsOffsetX,
offsetY: propsOffsetY,
maxWidth,
...otherStyle
} = shapeStyle;
const wordWrapWidth = getWordWrapWidthByBox(
keyShapeBox,
maxWidth,
this.zoomCache.zoom,
);
const positionPreset = {
x: keyShapeBox.center[0],
y: keyShapeBox.max[1],
@ -121,6 +213,7 @@ export abstract class BaseNode {
textAlign: 'center',
offsetX: 0,
offsetY: 0,
wordWrapWidth,
};
switch (position) {
case 'center':
@ -136,14 +229,14 @@ export abstract class BaseNode {
positionPreset.y = keyShapeBox.center[1];
positionPreset.textAlign = 'right';
positionPreset.textBaseline = 'middle';
positionPreset.offsetX = -4;
positionPreset.offsetX = -8;
break;
case 'right':
positionPreset.x = keyShapeBox.max[0];
positionPreset.y = keyShapeBox.center[1];
positionPreset.textAlign = 'left';
positionPreset.textBaseline = 'middle';
positionPreset.offsetX = 4;
positionPreset.offsetX = 8;
break;
default: // at bottom by default
positionPreset.offsetY = 4;
@ -163,17 +256,7 @@ export abstract class BaseNode {
...positionPreset,
...otherStyle,
};
const { shape, updateStyles } = this.upsertShape(
'text',
'labelShape',
style,
shapeMap,
);
if (isStyleAffectBBox('text', updateStyles)) {
this.boundsCache.labelShapeLocal = undefined;
}
return shape;
return this.upsertShape('text', 'labelShape', style, shapeMap, model);
}
public drawLabelBackgroundShape(
@ -184,46 +267,44 @@ export abstract class BaseNode {
): DisplayObject {
const { labelShape } = shapeMap;
if (!labelShape || !model.data.labelShape) return;
this.boundsCache.labelShapeLocal =
this.boundsCache.labelShapeLocal || labelShape.getLocalBounds();
if (
!this.boundsCache.labelShapeLocal ||
labelShape.getAttribute(LOCAL_BOUNDS_DIRTY_FLAG_KEY)
) {
this.boundsCache.labelShapeLocal = labelShape.getLocalBounds();
labelShape.setAttribute(LOCAL_BOUNDS_DIRTY_FLAG_KEY, false);
}
const { labelShapeLocal: textBBox } = this.boundsCache;
const { padding: propsPadding, ...backgroundStyle } =
const { padding, ...backgroundStyle } =
this.mergedStyles.labelBackgroundShape;
const padding = formatPadding(propsPadding, DEFAULT_LABEL_BG_PADDING);
const { balanceRatio = 1 } = this.zoomCache;
const bgStyle: any = {
fill: '#fff',
...backgroundStyle,
x: textBBox.min[0] - padding[3],
y: textBBox.min[1] - padding[0],
y: textBBox.min[1] - padding[0] / balanceRatio,
width: textBBox.max[0] - textBBox.min[0] + padding[1] + padding[3],
height: textBBox.max[1] - textBBox.min[1] + padding[0] + padding[2],
height:
textBBox.max[1] -
textBBox.min[1] +
(padding[0] + padding[2]) / balanceRatio,
};
const labelShapeAttr = labelShape.attributes;
if (labelShapeAttr.transform) {
bgStyle.transform = labelShapeAttr.transform;
bgStyle.transformOrigin = 'center';
if (labelShapeAttr.textAlign === 'left') {
bgStyle.transformOrigin = `${padding[3]} ${
padding[0] + bgStyle.height / 2
}`;
}
if (labelShapeAttr.textAlign === 'right') {
bgStyle.transformOrigin = `${padding[3] + bgStyle.width} ${
padding[0] + bgStyle.height / 2
}`;
}
}
return this.upsertShape('rect', 'labelBackgroundShape', bgStyle, shapeMap)
.shape;
return this.upsertShape(
'rect',
'labelBackgroundShape',
bgStyle,
shapeMap,
model,
);
}
public drawIconShape(
model: NodeDisplayModel,
shapeMap: NodeShapeMap,
diffData?: { previous: NodeModelData; current: NodeModelData },
diffState?: { oldState: State[]; newState: State[] },
diffState?: { previous: State[]; current: State[] },
): DisplayObject {
const { iconShape: shapeStyle } = this.mergedStyles;
const {
@ -255,7 +336,8 @@ export abstract class BaseNode {
'iconShape',
shapeStyle as GShapeStyle,
shapeMap,
).shape;
model,
);
}
public drawHaloShape(
@ -276,7 +358,8 @@ export abstract class BaseNode {
isBillboard: true,
},
shapeMap,
).shape;
model,
);
}
public drawAnchorShapes(
@ -287,19 +370,12 @@ export abstract class BaseNode {
): {
[shapeId: string]: DisplayObject;
} {
const { anchorShapes: configs, keyShape: keyShapeStyle } =
const { anchorShapes: commonStyle, keyShape: keyShapeStyle } =
this.mergedStyles;
const commonStyle = {};
const individualConfigs = [];
Object.keys(configs).forEach((key) => {
const value = configs[key];
if (typeof value === 'object' && value.position) {
individualConfigs.push(value);
} else {
commonStyle[key] = value;
}
});
const individualConfigs = Object.values(this.mergedStyles).filter(
(style) => style.tag === 'anchorShape',
);
if (!individualConfigs.length) return;
this.boundsCache.keyShapeLocal =
this.boundsCache.keyShapeLocal || shapeMap.keyShape.getLocalBounds();
@ -322,7 +398,8 @@ export abstract class BaseNode {
...style,
} as GShapeStyle,
shapeMap,
).shape;
model,
);
});
return shapes;
}
@ -335,18 +412,10 @@ export abstract class BaseNode {
): {
[shapeId: string]: DisplayObject;
} {
const configs = this.mergedStyles.badgeShapes;
const commonStyle = {};
const individualConfigs = [];
Object.keys(configs).forEach((key) => {
const value = configs[key];
if (typeof value === 'object' && value.position) {
individualConfigs.push(value);
} else {
commonStyle[key] = value;
}
});
const commonStyle = this.mergedStyles.badgeShapes;
const individualConfigs = Object.values(this.mergedStyles).filter(
(style) => style.tag === 'badgeShape',
);
if (!individualConfigs.length) return {};
this.boundsCache.keyShapeLocal =
this.boundsCache.keyShapeLocal || shapeMap.keyShape.getLocalBounds();
@ -430,7 +499,8 @@ export abstract class BaseNode {
zIndex: (zIndex as number) + 1,
} as GShapeStyle,
shapeMap,
).shape;
model,
);
const bbox = shapes[id].getLocalBounds();
const bgShapeId = `${position}BadgeBackgroundShape`;
const bgWidth =
@ -452,7 +522,8 @@ export abstract class BaseNode {
...otherStyles,
} as GShapeStyle,
shapeMap,
).shape;
model,
);
});
return shapes;
@ -467,15 +538,115 @@ export abstract class BaseNode {
return {};
}
/**
* The listener for graph zooming.
* 1. show / hide some shapes while zoom level changed;
* 2. change the shapes' sizes to make them have same visual size while zooming, e.g. labelShape, labelBackgroundShape.
* @param shapeMap
* @param zoom
*/
public onZoom = (shapeMap: NodeShapeMap, zoom: number) => {
this.balanceShapeSize(shapeMap, zoom);
// zoomLevel changed
if (!this.zoomStrategy) return;
const { levels } = this.zoomStrategy;
// last zoom ratio responsed by zoom changing, which might not equal to zoom.previous in props since the function is debounced.
const {
levelShapes,
hiddenShape,
animateConfig,
zoomLevel: previousLevel,
} = this.zoomCache;
const currentLevel = getZoomLevel(levels, zoom);
if (currentLevel < previousLevel) {
// zoomLevel changed, from higher to lower, hide something
levelShapes[currentLevel + 1]?.forEach((id) =>
fadeOut(id, shapeMap[id], hiddenShape, animateConfig),
);
} else if (currentLevel > previousLevel) {
// zoomLevel changed, from lower to higher, show something
levelShapes[String(currentLevel)]?.forEach((id) =>
fadeIn(
id,
shapeMap[id],
this.mergedStyles[id] ||
this.mergedStyles[id.replace('Background', '')],
hiddenShape,
animateConfig,
),
);
}
this.zoomCache.zoomLevel = currentLevel;
this.zoomCache.zoom = zoom;
};
/**
* Update the shapes' sizes e.g. labelShape, labelBackgroundShape, to keep the visual size while zooming.
* @param shapeMap
* @param zoom
* @returns
*/
private balanceShapeSize(shapeMap: NodeShapeMap, zoom: number) {
// balance the size for label, badges
const { labelShape, labelBackgroundShape } = shapeMap;
const balanceRatio = 1 / zoom || 1;
this.zoomCache.balanceRatio = balanceRatio;
const { labelShape: labelStyle } = this.mergedStyles;
const { position = 'bottom' } = labelStyle;
if (!labelShape) return;
if (position === 'bottom') labelShape.style.transformOrigin = '0';
else labelShape.style.transformOrigin = '';
labelShape.style.transform = `scale(${balanceRatio}, ${balanceRatio})`;
const wordWrapWidth = this.zoomCache.wordWrapWidth * zoom;
labelShape.style.wordWrapWidth = wordWrapWidth;
if (!labelBackgroundShape) return;
const { padding } = this.mergedStyles.labelBackgroundShape;
const { width, height } = labelBackgroundShape.attributes;
const [paddingTop, paddingRight, paddingBottom, paddingLeft] =
padding as number[];
switch (position) {
case 'top':
labelBackgroundShape.style.transformOrigin = `${
paddingLeft + (width - paddingLeft - paddingRight) / 2
} ${height - paddingBottom}`;
break;
case 'left':
labelBackgroundShape.style.transformOrigin = `${width - paddingRight} ${
paddingTop + (height - paddingTop - paddingBottom) / 2
}`;
break;
case 'right':
labelBackgroundShape.style.transformOrigin = `${paddingLeft} ${
paddingTop + (height - paddingTop - paddingBottom) / 2
}`;
break;
case 'bottom':
default:
labelBackgroundShape.style.transformOrigin = `${
paddingLeft + (width - paddingLeft - paddingRight) / 2
} ${paddingTop + (height - paddingTop - paddingBottom) / 2}`;
}
// only scale y-asix, to expand the text range while zoom-in
labelBackgroundShape.style.transform = `scale(1, ${balanceRatio})`;
}
public upsertShape(
type: SHAPE_TYPE | SHAPE_TYPE_3D,
id: string,
style: ShapeStyle,
shapeMap: { [shapeId: string]: DisplayObject },
): {
updateStyles: ShapeStyle;
shape: DisplayObject;
} {
return upsertShape(type as SHAPE_TYPE, id, style as GShapeStyle, shapeMap);
shapeMap: NodeShapeMap,
model: NodeDisplayModel,
): DisplayObject {
return upsertShape(
type as SHAPE_TYPE,
id,
style as GShapeStyle,
shapeMap,
model,
);
}
}

View File

@ -33,7 +33,7 @@ export abstract class BaseNode3D extends BaseNode {
model: NodeDisplayModel,
shapeMap: NodeShapeMap,
diffData?: { previous: NodeModelData; current: NodeModelData },
diffState?: { oldState: State[]; newState: State[] },
diffState?: { previous: State[]; current: State[] },
): DisplayObject {
return super.drawLabelShape(model, shapeMap, diffData, diffState);
}
@ -43,7 +43,7 @@ export abstract class BaseNode3D extends BaseNode {
model: NodeDisplayModel,
shapeMap: NodeShapeMap,
diffData?: { previous: NodeModelData; current: NodeModelData },
diffState?: { oldState: State[]; newState: State[] },
diffState?: { previous: State[]; current: State[] },
): DisplayObject {
return super.drawIconShape(model, shapeMap, diffData, diffState);
}
@ -67,7 +67,7 @@ export abstract class BaseNode3D extends BaseNode {
isBillboard: true,
},
shapeMap,
).shape;
);
}
/**
@ -116,10 +116,7 @@ export abstract class BaseNode3D extends BaseNode {
id: string,
style: ShapeStyle,
shapeMap: { [shapeId: string]: DisplayObject },
): {
shape: DisplayObject;
updateStyles: ShapeStyle;
} {
): DisplayObject {
return upsertShape3D(type, id, style as GShapeStyle, shapeMap, this.device);
}
}

View File

@ -7,7 +7,6 @@ import {
NodeShapeStyles,
} from '../../../types/node';
import { BaseNode } from './base';
import { isStyleAffectBBox } from 'util/shape';
export class CircleNode extends BaseNode {
override defaultStyles = {
@ -103,15 +102,12 @@ export class CircleNode extends BaseNode {
diffData?: { previous: NodeModelData; current: NodeModelData },
diffState?: { previous: State[]; current: State[] },
): DisplayObject {
const { shape, updateStyles } = this.upsertShape(
return this.upsertShape(
'circle',
'keyShape',
this.mergedStyles.keyShape,
shapeMap,
model,
);
if (isStyleAffectBBox('circle', updateStyles)) {
this.boundsCache.keyShapeLocal = shape.getLocalBounds();
}
return shape;
}
}

View File

@ -94,6 +94,6 @@ export class SphereNode extends BaseNode3D {
'keyShape',
this.mergedStyles.keyShape,
shapeMap,
).shape;
);
}
}

View File

@ -30,6 +30,18 @@ export default {
'#5241A8',
'#95CF21',
],
zoomStrategy: {
levels: [
{ range: [0, 0.65] },
{ range: [0.65, 0.8] },
{ range: [0.8, 1.6], primary: true },
{ range: [1.6, 2] },
{ range: [2, Infinity] },
],
animateCfg: {
duration: 200,
},
},
styles: [
{
default: {
@ -44,32 +56,40 @@ export default {
...DEFAULT_TEXT_STYLE,
fill: '#000',
position: 'bottom',
offsetY: 4,
zIndex: 2,
showLevel: 0,
maxWidth: '200%',
textOverflow: 'ellipsis',
wordWrap: true,
maxLines: 1,
},
labelBackgroundShape: {
padding: [4, 4, 4, 4],
padding: [2, 4, 2, 4],
lineWidth: 0,
fill: '#fff',
opacity: 0.75,
zIndex: -1,
showLevel: 0,
},
iconShape: {
...DEFAULT_TEXT_STYLE,
fill: '#fff',
fontSize: 16,
zIndex: 1,
showLevel: -1,
},
anchorShapes: {
lineWidth: 1,
stroke: 'rgba(0, 0, 0, 0.65)',
zIndex: 2,
r: 3,
showLevel: 0,
},
badgeShapes: {
color: 'rgb(140, 140, 140)',
textColor: '#fff',
zIndex: 2,
zIndex: 3,
showLevel: -1,
},
},
selected: {
@ -140,6 +160,18 @@ export default {
'#A192E8',
'#CEFB75',
],
zoomStrategy: {
levels: [
{ range: [0, 0.65] },
{ range: [0.65, 0.8] },
{ range: [0.8, 1.6], primary: true },
{ range: [1.6, 2] },
{ range: [2, Infinity] },
],
animateCfg: {
duration: 200,
},
},
styles: [
{
default: {
@ -155,6 +187,11 @@ export default {
position: 'middle',
textBaseline: 'middle',
zIndex: 2,
textOverflow: 'ellipsis',
wordWrap: true,
maxLines: 1,
maxWidth: '60%',
showLevel: 0,
},
labelBackgroundShape: {
padding: [4, 4, 4, 4],
@ -162,6 +199,7 @@ export default {
fill: '#fff',
opacity: 0.75,
zIndex: 1,
showLevel: 0,
},
iconShape: {
...DEFAULT_TEXT_STYLE,
@ -169,6 +207,7 @@ export default {
fontSize: 16,
zIndex: 2,
offsetX: -10,
showLevel: -1,
},
},
selected: {
@ -278,6 +317,6 @@ export default {
],
},
canvas: {
backgroundColor: '#fff',
backgroundColor: '#000',
},
} as ThemeSpecification;

View File

@ -6,7 +6,6 @@ import {
} from '../../types/theme';
import { mergeStyles } from '../../util/shape';
import BaseThemeSolver, { ThemeSpecificationMap } from './base';
import { GraphData } from 'types';
interface SpecThemeSolverOptions {
base: 'light' | 'dark';
@ -47,6 +46,7 @@ export default class SpecThemeSolver extends BaseThemeSolver {
if (!specification[itemType]) return;
let {
palette = mergedSpec[itemType].palette,
zoomStrategy = mergedSpec[itemType].zoomStrategy,
dataTypeField,
getStyleSets,
} = specification[itemType];
@ -96,6 +96,7 @@ export default class SpecThemeSolver extends BaseThemeSolver {
mergedSpec[itemType] = {
dataTypeField,
palette,
zoomStrategy,
styles: mergedStyles,
};
});

View File

@ -1,11 +1,6 @@
import { IAnimationEffectTiming } from '@antv/g';
export interface AnimateCfg {
/**
* Whether enable animation.
* @type {boolean}
*/
enable: boolean;
/**
* Duration of one animation.
* @type {number}
@ -22,10 +17,10 @@ export interface AnimateCfg {
*/
delay?: number;
/**
* Whether repeat.
* @type {boolean}
* Iteration number for the animation, Inifinity means repeat.
* @type {number | typeof Infinity}
*/
repeat?: boolean;
iterations?: number | typeof Infinity;
/**
* Called after the animation is finished.
* @type {function}
@ -43,12 +38,32 @@ export interface AnimateCfg {
resumeCallback?: () => void;
}
export type AnimateWhen = 'show' | 'exit' | 'update' | 'last';
export type AnimateTiming = 'buildIn' | 'buildOut' | 'show' | 'hide' | 'update';
export interface AnimateAttr {
when: AnimateWhen;
type: string;
[param: string]: unknown;
export interface IAnimate {
// style fields to animate
fields?: string[];
// shapeId for the animate, 'group' by default, means animation on whole graphics group
shapeId?: string;
// the order of the animate, 0 by dfault
order?: number;
// animate options
duration?: number;
interations?: number;
easing?: string;
delay?: number;
}
export interface IStateAnimate extends IAnimate {
states: string[];
}
export interface IAnimates {
buildIn?: IAnimate[];
buildOut?: IAnimate[];
show?: IAnimate[];
hide?: IAnimate[];
update?: (IAnimate | IStateAnimate)[];
}
export type CameraAnimationOptions = Pick<

View File

@ -1,5 +1,5 @@
import { Node as GNode, PlainObject } from '@antv/graphlib';
import { AnimateAttr } from './animate';
import { IAnimates } from './animate';
import { Padding } from './common';
import {
BadgePosition,
@ -107,6 +107,7 @@ export interface ComboShapesEncode extends ShapesEncode {
}
export interface ComboEncode extends ComboShapesEncode {
type?: string | Encode<string>;
animates?: IAnimates;
}
// TODO

View File

@ -1,5 +1,6 @@
import { DisplayObject } from '@antv/g';
import { Edge as GEdge, PlainObject } from '@antv/graphlib';
import { IAnimates } from './animate';
import {
BadgePosition,
Encode,
@ -61,6 +62,8 @@ export interface EdgeShapeStyles extends ItemShapeStyles {
offsetX?: number;
offsetY?: number;
autoRotate?: boolean;
// if it is a string, means the percentage of the keyShape, number means pixel
maxWidth?: string | number;
};
labelBackgroundShape?: ShapeStyle & {
padding?: number | number[];
@ -98,6 +101,7 @@ export interface EdgeShapesEncode extends ShapesEncode {
}
export interface EdgeEncode extends EdgeShapesEncode {
type?: string | Encode<string>;
animates?: IAnimates;
}
export interface EdgeShapeMap {

View File

@ -205,6 +205,22 @@ export interface IGraph<
| EdgeModel[]
| ComboModel[];
/**
* Update one or more nodes' positions,
* do not update other styles which leads to better performance than updating positions by updateData.
* @param models new configurations with x and y for every node, which has id field to indicate the specific item
* @param {boolean} stack whether push this operation into graph's stack, true by default
* @group Data
*/
updateNodePosition: (
models:
| Partial<NodeUserModel>
| Partial<
ComboUserModel | Partial<NodeUserModel>[] | Partial<ComboUserModel>[]
>,
stack?: boolean,
) => NodeModel | ComboModel | NodeModel[] | ComboModel[];
// ===== view operations =====
/**
@ -378,14 +394,14 @@ export interface IGraph<
* @returns
* @group Data
*/
showItem: (ids: ID | ID[]) => void;
showItem: (ids: ID | ID[], disableAniamte?: boolean) => void;
/**
* Hide the item(s).
* @param ids the item id(s) to be hidden
* @returns
* @group Item
*/
hideItem: (ids: ID | ID[]) => void;
hideItem: (ids: ID | ID[], disableAniamte?: boolean) => void;
/**
* Set state for the item(s).
* @param ids the id(s) for the item(s) to be set

View File

@ -42,6 +42,7 @@ export interface Hooks {
changes: GraphChange<NodeModelData, EdgeModelData>[];
graphCore: GraphCore;
theme: ThemeSpecification;
action?: 'updateNodePosition';
}>;
render: IHook<{
graphCore: GraphCore;
@ -64,6 +65,7 @@ export interface Hooks {
itemvisibilitychange: IHook<{
ids: ID[];
value?: boolean;
animate?: boolean;
}>;
transientupdate: IHook<{
type: ITEM_TYPE | SHAPE_TYPE;

View File

@ -10,8 +10,11 @@ import {
PolylineStyleProps,
TextStyleProps,
ImageStyleProps,
Group,
DisplayObject,
IAnimation,
} from '@antv/g';
import { AnimateAttr } from './animate';
import { AnimateCfg, IAnimates } from './animate';
import {
ComboDisplayModel,
ComboEncode,
@ -24,6 +27,7 @@ import {
EdgeEncode,
EdgeModel,
EdgeModelData,
EdgeShapeMap,
EdgeUserModel,
} from './edge';
import {
@ -31,6 +35,7 @@ import {
NodeEncode,
NodeModel,
NodeModelData,
NodeShapeMap,
NodeUserModel,
} from './node';
import {
@ -55,7 +60,8 @@ export type GShapeStyle = CircleStyleProps &
export type ShapeStyle = Partial<
GShapeStyle & {
animate?: AnimateAttr;
animates?: IAnimates;
showLevel?: number;
}
>;
export interface Encode<T> {
@ -65,7 +71,7 @@ export interface Encode<T> {
export interface ShapeAttrEncode {
[shapeAttr: string]: unknown | Encode<unknown>;
animate?: AnimateAttr | Encode<AnimateAttr>;
animates?: IAnimates | Encode<IAnimates>;
}
export interface LabelBackground {
@ -82,7 +88,7 @@ export interface ShapesEncode {
otherShapes?: {
[shapeId: string]: {
[shapeAtrr: string]: unknown | Encode<unknown>;
animate: AnimateAttr | Encode<AnimateAttr>;
animates: IAnimates | Encode<IAnimates>;
};
};
}
@ -118,7 +124,7 @@ export type DisplayMapper =
export type State = {
name: string;
value: boolean | string;
value: boolean | string | number;
};
export enum BadgePosition {
@ -147,11 +153,32 @@ export type ItemShapeStyles = {
ImageStyleProps & {
offsetX?: number;
offsetY?: number;
showLevel?: number;
}
>;
haloShape?: ShapeStyle;
group?: ShapeStyle;
otherShapes?: {
[shapeId: string]: ShapeStyle;
};
animates?: IAnimates;
};
export interface ZoomStrategy {
levels: {
range: [number, number];
primary: boolean;
}[];
animateCfg: AnimateCfg;
}
export interface ZoomStrategyObj {
levels: {
[levelIdx: number]: [number, number];
};
animateCfg: AnimateCfg;
}
/**
* Base item of node / edge / combo.
*/
@ -159,25 +186,58 @@ export interface IItem {
destroyed: boolean;
/** Inner model. */
model: ItemModel;
// /** Display model, user will not touch it. */
// displayModel: ItemDisplayModel;
// /** The graphic group for item drawing. */
// group: Group;
// /** Visibility. */
// visible: boolean;
// /** The states on the item. */
// states: {
// name: string,
// value: string | boolean
// }[];
// type: 'node' | 'edge' | 'combo';
/** Display model, user will not touch it. */
displayModel: ItemDisplayModel;
/** The style mapper configured at graph with field name 'node' / 'edge' / 'combo'. */
mapper: DisplayMapper;
/** The state sstyle mapper configured at traph with field name 'nodeState' / 'edgeState' / 'comboState'. */
stateMapper: {
[stateName: string]: DisplayMapper;
};
/** The graphic group for item drawing. */
group: Group;
/** The keyShape of the item. */
keyShape: DisplayObject;
/** render extension for this item. */
renderExt;
/** Visibility. */
visible: boolean;
/** The states on the item. */
states: {
name: string;
value: string | boolean;
}[];
/** The map caches the shapes of the item. The key is the shape id, the value is the g shape. */
shapeMap: NodeShapeMap | EdgeShapeMap;
afterDrawShapeMap: Object;
/** Item's type. Set to different value in implements. */
type: ITEM_TYPE;
/** Render extensions where could the renderExt be selected from according to the type. */
renderExtensions: any;
/** Cache the animation instances to stop at next lifecycle. */
animations: IAnimation[];
/** Theme styles to response the state changes. */
themeStyles: {
default?: ItemShapeStyles;
[stateName: string]: ItemShapeStyles;
};
/** The zoom strategy to show and hide shapes according to their showLevel. */
zoomStrategy: ZoomStrategyObj;
/** Last zoom ratio. */
zoom: number;
/** Cache the chaging states which are not consomed by draw */
changedStates: string[];
/** The listener for the animations frames. */
onframe: Function;
/** Gets the inner model. */
// getModel: () => ItemModel;
/** Gets the id in model. */
getID: () => ID;
/** Gets the item's type. */
getType: () => 'node' | 'edge' | 'combo';
getType: () => ITEM_TYPE;
/** Initiate the item. */
init: (props) => void;
/**
* Draws the shapes.
* @internal
@ -196,16 +256,45 @@ export interface IItem {
diffData: { previous: ItemModelData; current: ItemModelData },
isUpdate?: boolean,
) => void;
/**
* Update the group's position, e.g. node, combo.
* @param displayModel
* @param diffData
* @param onfinish
* @returns
*/
updatePosition: (
displayModel: ItemDisplayModel,
diffData?: { previous: ItemModelData; current: ItemModelData },
onfinish?: Function,
) => void;
/**
* Maps (mapper will be function, value, or encode format) model to displayModel and find out the shapes to be update for incremental updating.
* @param model inner model
* @param diffData changes from graphCore changed event
* @param isReplace whether replace the whole data or partial update
* @returns
*/
getDisplayModelAndChanges: (
innerModel: ItemModel,
diffData?: { previous: ItemModelData; current: ItemModelData },
isReplace?: boolean,
) => {
model: ItemDisplayModel;
typeChange?: boolean;
};
/** Show the item. */
show: (animate: boolean) => void;
/** Hides the item. */
hide: (animate: boolean) => void;
/** Returns the visibility of the item. */
isVisible: () => boolean;
/** Puts the item to the front in its graphic group. */
toFront: () => void;
/** Puts the item to the back in its graphic group. */
toBack: () => void;
/** Showsthe item. */
show: () => void;
/** Hides the item. */
hide: () => void;
/** Returns the visibility of the item. */
isVisible: () => boolean;
/** Sets a state value to the item. */
setState: (state: string, value: string | boolean) => void;
/** Returns the state if it is true/string. Returns false otherwise. */
@ -218,9 +307,17 @@ export interface IItem {
/** Set all the state to false. */
clearStates: (states?: string[]) => void;
/** Get the rendering bounding box for the keyShape. */
getKeyBBox(): AABB;
getKeyBBox: () => AABB;
/** Get the local bounding box for the keyShape. */
getLocalKeyBBox: () => AABB;
/** Get the rendering bounding box for the whole item. */
getBBox(): AABB;
getBBox: () => AABB;
/** Stop all the animations on the item. */
stopAnimations: () => void;
/** Animations' frame listemer. */
animateFrameListener: Function;
/** Call render extension's onZoom to response the graph zooming. */
updateZoom: (zoom: number) => void;
/** Destroy the item. */
destroy: () => void;
}

View File

@ -1,6 +1,6 @@
import { DisplayObject, Point } from '@antv/g';
import { Node as GNode, PlainObject } from '@antv/graphlib';
import { AnimateAttr } from './animate';
import { IAnimates } from './animate';
import {
BadgePosition,
Encode,
@ -78,6 +78,8 @@ export interface NodeShapeStyles extends ItemShapeStyles {
position?: 'top' | 'bottom' | 'left' | 'right' | 'center';
offsetX?: number;
offsetY?: number;
// string means the percentage of the keyShape, number means pixel
maxWidth?: string | number;
};
labelBackgroundShape?: ShapeStyle & {
padding?: number | number[];
@ -138,6 +140,7 @@ export interface NodeShapesEncode extends ShapesEncode {
}
export interface NodeEncode extends NodeShapesEncode {
type?: string | Encode<string>;
animates?: IAnimates;
}
export interface NodeShapeMap {

View File

@ -1,5 +1,6 @@
import { ComboShapeStyles } from './combo';
import { EdgeShapeStyles } from './edge';
import { ZoomStrategy } from './item';
import { NodeShapeStyles } from './node';
export interface ThemeOption {}
@ -72,16 +73,19 @@ export interface NodeThemeSpecifications {
dataTypeField?: string;
palette?: string[] | { [dataTypeValue: string]: string };
styles?: NodeStyleSets;
zoomStrategy?: ZoomStrategy;
}
export interface EdgeThemeSpecifications {
dataTypeField?: string;
palette?: string[] | { [dataTypeValue: string]: string };
styles?: EdgeStyleSets;
zoomStrategy?: ZoomStrategy;
}
export interface ComboThemeSpecifications {
dataTypeField?: string;
palette?: string[] | { [dataTypeValue: string]: string };
styles?: ComboStyleSets;
zoomStrategy?: ZoomStrategy;
}
/**
* Theme specification

View File

@ -0,0 +1,423 @@
import {
DisplayObject,
Group,
IAnimation,
Line,
Path,
Polyline,
} from '@antv/g';
import {
AnimateTiming,
IAnimate,
IAnimates,
IStateAnimate,
} from '../types/animate';
import { ItemShapeStyles, ShapeStyle } from '../types/item';
import { isArrayOverlap, replaceElements } from './array';
/**
* Initial(timing = show) shape animation start from init shape styles, and end to the shape's style config.
*/
export const getShapeAnimateBeginStyles = (shape) => {
if (!shape) return {};
const shapeType = shape.nodeName;
const commonStyles = {
opacity: 0,
strokeOpacity: 0,
lineWidth: 1,
offsetDistance: 0,
};
if (['line', 'polyline', 'path'].includes(shapeType)) {
const totalLength = shape.getTotalLength();
return {
lineDash: [0, totalLength],
...commonStyles,
};
} else if (shapeType === 'circle') {
return {
r: 0,
...commonStyles,
};
} else if (shapeType === 'ellipse') {
return {
rx: 0,
ry: 0,
...commonStyles,
};
} else if (shapeType === 'text') {
return {
fontSize: 0,
opacity: 0,
strokeOpacity: 0,
offsetDistance: 0,
};
}
return {
width: 0,
height: 0,
...commonStyles,
};
};
/**
* Initial(timing = show) group animation start from GROUP_ANIMATE_STYLES[0], and end to GROUP_ANIMATE_STYLES[1].
*/
export const GROUP_ANIMATE_STYLES = [
{
opacity: 0,
transform: 'scale(0)',
},
{
opacity: 1,
transform: 'scale(1)',
},
];
/**
* Default animate options for different timing.
*/
export const DEFAULT_ANIMATE_CFG = {
buildIn: {
duration: 500,
easing: 'cubic-bezier(0.250, 0.460, 0.450, 0.940)',
iterations: 1,
delay: 1000,
fill: 'both',
},
show: {
duration: 500,
easing: 'cubic-bezier(0.250, 0.460, 0.450, 0.940)',
iterations: 1,
fill: 'both',
},
update: {
duration: 500,
easing: 'cubic-bezier(0.250, 0.460, 0.450, 0.940)',
iterations: 1,
fill: 'both',
},
zoom: {
duration: 200,
easing: 'linear',
iterations: 1,
delay: 0,
fill: 'both',
},
};
/**
* Get different key value map between style1 and style2.
* @param style1
* @param style2
* @returns
*/
const getStyleDiff = (style1: ShapeStyle, style2: ShapeStyle) => {
const diff = [{}, {}];
Object.keys(style1).forEach((key) => {
if (style2[key] !== style1[key]) {
diff[0][key] = style1[key];
diff[1][key] = style2[key];
}
});
return diff;
};
/**
* Grouping the animates at a timing by order.
* @param animates
* @param timing
* @returns
*/
const groupTimingAnimates = (
animates: IAnimates,
segmentedTiming: AnimateTiming | 'stateUpdate',
changedStates: string[],
) => {
const timingAnimateGroups = {};
const isStateUpdate = segmentedTiming === 'stateUpdate';
animates[isStateUpdate ? 'update' : segmentedTiming].forEach((item: any) => {
const { order = 0, states } = item;
if (
!isStateUpdate ||
(isStateUpdate && isArrayOverlap(states, changedStates))
) {
timingAnimateGroups[order] = timingAnimateGroups[order] || [];
timingAnimateGroups[order].push(item);
}
});
return timingAnimateGroups;
};
/**
* Execute animations in order at a timing.
* @param shapeMap
* @param group
* @param timingAnimates
* @param targetStylesMap
* @param timing
* @param onfinish
*/
const runAnimateGroupOnShapes = (
shapeMap: { [shapeId: string]: DisplayObject },
group: Group,
timingAnimates: IAnimate[],
targetStylesMap: ItemShapeStyles,
timing: AnimateTiming,
onfinish: Function,
cancelAnimations: Function,
canceled: Boolean,
) => {
let maxDuration = -Infinity;
let maxDurationIdx = -1;
let hasCanceled = canceled;
const isOut = timing === 'buildOut' || timing === 'hide';
const animations = timingAnimates.map((animate: any, i) => {
const { fields, shapeId, order, states, ...animateCfg } = animate;
const animateConfig = {
...DEFAULT_ANIMATE_CFG[timing],
...animateCfg,
};
const { duration } = animateConfig;
let animation;
if (!shapeId || shapeId === 'group') {
// animate on group
const usingFields = [];
let hasOpacity = false;
fields?.forEach((field) => {
if (field === 'size') usingFields.push('transform');
else if (field !== 'opacity') usingFields.push(field);
else hasOpacity = true;
});
const targetStyle =
targetStylesMap.group || GROUP_ANIMATE_STYLES[isOut ? 0 : 1];
if (hasCanceled) {
Object.keys(targetStyle).forEach((key) => {
group.style[key] = targetStyle[key];
});
} else {
if (hasOpacity) {
// opacity on group, animate on all shapes
Object.keys(shapeMap).forEach((shapeId) => {
const { opacity: targetOpaicty = 1 } =
targetStylesMap[shapeId] ||
targetStylesMap.otherShapes?.[shapeId] ||
{};
animation = runAnimateOnShape(
shapeMap[shapeId],
['opacity'],
{ opacity: targetOpaicty },
getShapeAnimateBeginStyles(shapeMap[shapeId]),
animateConfig,
);
});
}
if (usingFields.length) {
animation = runAnimateOnShape(
group,
usingFields,
targetStyle,
GROUP_ANIMATE_STYLES[isOut ? 1 : 0],
animateConfig,
);
}
}
} else {
const shape = shapeMap[shapeId];
if (shape && shape.style.display !== 'none') {
const targetStyle =
targetStylesMap[shapeId] ||
targetStylesMap.otherShapes?.[shapeId] ||
{};
if (hasCanceled) {
Object.keys(targetStyle).forEach((key) => {
shape.style[key] = targetStyle[key];
});
} else {
animation = runAnimateOnShape(
shape,
fields,
targetStyle,
getShapeAnimateBeginStyles(shape),
animateConfig,
);
}
}
}
if (maxDuration < duration && animation) {
maxDuration = duration;
maxDurationIdx = i;
}
if (animation) {
animation.oncancel = () => {
hasCanceled = true;
cancelAnimations();
};
}
return animation;
});
if (maxDurationIdx > -1) animations[maxDurationIdx].onfinish = onfinish;
return animations;
};
/**
* Execute one animation.
* @param shape
* @param fields
* @param targetStyle
* @param beginStyle
* @param animateConfig
* @returns
*/
const runAnimateOnShape = (
shape: DisplayObject,
fields: string[],
targetStyle: ShapeStyle,
beginStyle: ShapeStyle,
animateConfig,
) => {
let animateArr;
if (!fields?.length) {
animateArr = getStyleDiff(shape.attributes, targetStyle);
} else {
animateArr = [{}, {}];
fields.forEach((key) => {
animateArr[0][key] = shape.attributes.hasOwnProperty(key)
? shape.style[key]
: beginStyle[key];
animateArr[1][key] = targetStyle[key];
if (key === 'lineDash' && animateArr[1][key].includes('100%')) {
const totalLength = (shape as Line | Polyline | Path).getTotalLength();
replaceElements(animateArr[1][key], '100%', totalLength);
}
});
}
if (JSON.stringify(animateArr[0]) === JSON.stringify(animateArr[1])) return;
return shape.animate(animateArr, animateConfig);
};
/**
* Handle shape and group animations.
* Should be called after canvas ready and shape appended.
* @param animates
* @param mergedStyles
* @param shapeMap
* @param group
* @param timing timing to match 'when' in the animate config in style
* @returns
*/
export const animateShapes = (
animates: IAnimates,
mergedStyles: ItemShapeStyles,
shapeMap: { [shapeId: string]: DisplayObject },
group: Group,
timing: AnimateTiming = 'buildIn',
changedStates: string[] = [],
onAnimatesFrame: Function = () => {},
onAnimatesEnd: Function = () => {},
): IAnimation[] => {
if (!animates?.[timing]) {
onAnimatesEnd();
return;
}
const segmentedTiming =
timing === 'update' && changedStates?.length ? 'stateUpdate' : timing;
const timingAnimateGroups = groupTimingAnimates(
animates,
segmentedTiming,
changedStates,
);
let i = 0;
const groupKeys = Object.keys(timingAnimateGroups);
if (!groupKeys.length) return;
let animations = [];
let canceled = false;
const onfinish = () => {
if (i >= groupKeys.length) {
onAnimatesEnd();
return;
}
const groupAnimations = runAnimateGroupOnShapes(
shapeMap,
group,
timingAnimateGroups[groupKeys[i]],
mergedStyles,
timing,
onfinish, // execute next order group
() => (canceled = true),
canceled,
).filter(Boolean);
groupAnimations.forEach((animation) => {
animation.onframe = onAnimatesFrame;
});
if (i === 0) {
// collect the first group animations
animations = groupAnimations;
}
i++;
};
onfinish();
// only animations with order 0 will be returned
return animations;
};
export const getAnimatesExcludePosition = (animates) => {
if (!animates.update) return animates;
const isGroupId = (id) => !id || id === 'group';
// const groupUpdateAnimates = animates.update.filter(
// ({ shapeId }) => isGroupId(shapeId),
// );
const excludedAnimates = [];
animates.update.forEach((animate) => {
const { shapeId, fields } = animate;
if (!isGroupId(shapeId)) {
excludedAnimates.push(animate);
return;
}
const newFields = fields;
let isGroupPosition = false;
if (fields.includes('x')) {
const xFieldIdx = newFields.indexOf('x');
newFields.splice(xFieldIdx, 1);
isGroupPosition = true;
}
if (fields.includes('y')) {
const yFieldIdx = newFields.indexOf('y');
newFields.splice(yFieldIdx, 1);
isGroupPosition = true;
}
if (isGroupPosition) {
if (newFields.length !== 0) {
// group animation but not on x and y
excludedAnimates.push({
...animate,
fields: newFields,
});
}
} else {
excludedAnimates.push(animate);
}
});
return {
...animates,
update: excludedAnimates,
};
};
export const fadeIn = (id, shape, style, hiddenShape, animateConfig) => {
// omit inexist shape and the shape which is not hidden by zoom changing
if (!shape || !hiddenShape[id]) return;
shape.show();
const { opacity = 1 } = style;
shape.animate([{ opacity: 0 }, { opacity }], animateConfig);
};
export const fadeOut = (id, shape, hiddenShape, animateConfig) => {
if (!shape?.isVisible()) return;
hiddenShape[id] = true;
const { opacity = 1 } = shape.attributes;
if (opacity === 0) return;
const animation = shape.animate([{ opacity }, { opacity: 0 }], animateConfig);
animation.onfinish = () => shape.hide();
};

View File

@ -46,3 +46,12 @@ export function intersectSet<T>(a: T[], b: T[]): T[] {
});
return result;
}
export function replaceElements(arr, target, replaceWith) {
for (let i = 0; i < arr.length; i++) {
if (arr[i] === target) {
arr[i] = replaceWith;
}
}
return arr;
}

View File

@ -3,7 +3,7 @@ import { Renderer as CanvasRenderer } from '@antv/g-canvas';
import { Renderer as SVGRenderer } from '@antv/g-svg';
import { Renderer as WebGLRenderer } from '@antv/g-webgl';
import { Plugin as Plugin3D } from '@antv/g-plugin-3d';
import { RendererName } from 'types/render';
import { RendererName } from '../types/render';
/**
* Create a canvas

View File

@ -36,7 +36,7 @@ export const upsertTransientItem = (
return transientItem;
}
if (item.type === 'node') {
const transientNode = item.clone(nodeGroup, onlyDrawKeyShape);
const transientNode = item.clone(nodeGroup, onlyDrawKeyShape, true);
transientItemMap[item.model.id] = transientNode;
return transientNode;
} else if (item.type === 'edge') {
@ -54,7 +54,13 @@ export const upsertTransientItem = (
transientItemMap,
onlyDrawKeyShape,
) as Node;
const transientEdge = item.clone(edgeGroup, source, target);
const transientEdge = item.clone(
edgeGroup,
source,
target,
undefined,
true,
);
transientItemMap[item.model.id] = transientEdge;
return transientEdge;
}

View File

@ -1,4 +1,4 @@
import { NodeDisplayModelData } from 'types/node';
import { NodeDisplayModelData } from '../types/node';
/**
* Default mapper to transform simple styles in inner data.

View File

@ -16,14 +16,17 @@ import {
import { clone, isArray, isNumber } from '@antv/util';
import { DEFAULT_LABEL_BG_PADDING } from '../constant';
import { Point } from '../types/common';
import { EdgeShapeMap } from '../types/edge';
import { EdgeDisplayModel, EdgeShapeMap } from '../types/edge';
import {
GShapeStyle,
SHAPE_TYPE,
ItemShapeStyles,
ShapeStyle,
SHAPE_TYPE_3D,
} from '../types/item';
import { NodeShapeMap } from '../types/node';
import { NodeDisplayModel, NodeShapeMap } from '../types/node';
import { ComboDisplayModel } from '../types';
import { getShapeAnimateBeginStyles } from './animate';
import { isArrayOverlap } from './array';
import { isBetween } from './math';
@ -39,44 +42,120 @@ export const ShapeTagMap = {
path: Path,
};
const LINE_TYPES = ['line', 'polyline', 'path'];
export const LOCAL_BOUNDS_DIRTY_FLAG_KEY = 'data-item-local-bounds-dirty';
export const createShape = (
type: SHAPE_TYPE,
style: GShapeStyle,
id: string,
) => {
const ShapeClass = ShapeTagMap[type];
return new ShapeClass({ style, id });
const shape = new ShapeClass({ id, style });
if (LINE_TYPES.includes(type)) {
shape.style.increasedLineWidthForHitTesting = Math.max(
shape.style.lineWidth as number,
6,
);
}
return shape;
};
/**
* Collect the fields to be animated at the timing on shape with shapeId.
* @param animates animate configs
* @param timing
* @param shapeId
* @returns
*/
const findAnimateFields = (animates, timing, shapeId) => {
if (!animates?.[timing]) return [];
let animateFields = [];
animates[timing].forEach(({ fields, shapeId: animateShapeId }) => {
if (animateShapeId === shapeId) {
animateFields = animateFields.concat(fields);
} else if (
(!animateShapeId || animateShapeId === 'group') &&
fields.includes('opacity')
) {
// group opacity, all shapes animates with opacity
animateFields.push('opacity');
}
});
if (animateFields.includes(undefined)) {
// there is an animate on all styles
return [];
}
return animateFields;
};
/**
* Create (if does not exit in shapeMap) or update the shape according to the configurations.
* @param type shape's type
* @param id unique string to indicates the shape
* @param style style to be updated
* @param shapeMap the shape map of a node / edge / combo
* @param model data model of the node / edge / combo
* @returns
*/
export const upsertShape = (
type: SHAPE_TYPE,
id: string,
style: GShapeStyle,
shapeMap: { [shapeId: string]: DisplayObject },
): {
updateStyles: ShapeStyle;
shape: DisplayObject;
} => {
model?: NodeDisplayModel | EdgeDisplayModel | ComboDisplayModel,
): DisplayObject => {
let shape = shapeMap[id];
let updateStyles = {};
const { animates, disableAnimate } = model?.data || {};
if (!shape) {
// create
shape = createShape(type, style, id);
updateStyles = style;
// find the animate styles, set them to be INIT_SHAPE_STYLES
if (!disableAnimate && animates) {
const animateFields = findAnimateFields(animates, 'buildIn', id);
const initShapeStyles = getShapeAnimateBeginStyles(shape);
animateFields.forEach((key) => {
shape.style[key] = initShapeStyles[key];
});
}
shape.setAttribute(LOCAL_BOUNDS_DIRTY_FLAG_KEY, true);
} else if (shape.nodeName !== type) {
// remove and create for the shape changed type
shape.remove();
shape = createShape(type, style, id);
updateStyles = style;
shape.setAttribute(LOCAL_BOUNDS_DIRTY_FLAG_KEY, true);
} else {
const updateStyles = {};
const oldStyles = shape.attributes;
Object.keys(style).forEach((key) => {
if (oldStyles[key] !== style[key]) {
updateStyles[key] = style[key];
shape.style[key] = style[key];
}
});
// update
if (disableAnimate || !animates?.update) {
// update all the style directly when there are no animates for update timing
Object.keys(style).forEach((key) => {
if (oldStyles[key] !== style[key]) {
updateStyles[key] = style[key];
shape.style[key] = style[key];
}
});
} else {
// update the styles excludes the ones in the animate fields
const animateFields = findAnimateFields(animates, 'update', id);
if (!animateFields.length) return shape;
Object.keys(style).forEach((key) => {
if (oldStyles[key] !== style[key]) {
updateStyles[key] = style[key];
if (!animateFields.includes(key)) {
shape.style[key] = style[key];
}
}
});
}
if (isStyleAffectBBox(type, updateStyles)) {
shape.setAttribute(LOCAL_BOUNDS_DIRTY_FLAG_KEY, true);
}
}
shapeMap[id] = shape;
return { shape, updateStyles };
return shape;
};
export const getGroupSucceedMap = (
@ -124,11 +203,15 @@ export const updateShapes = (
if (prevShape !== newShape) {
prevShape.remove();
}
group.appendChild(newShape);
if (newShape.style.display !== 'none') {
group.appendChild(newShape);
}
} else if (!prevShape && newShape) {
// add newShapeMap - prevShapeMap
finalShapeMap[id] = newShape;
group.appendChild(newShape);
if (newShape.style.display !== 'none') {
group.appendChild(newShape);
}
} else if (prevShape && !newShape && removeDiff) {
// remove prevShapeMap - newShapeMap
delete finalShapeMap[id];
@ -428,11 +511,14 @@ const FEILDS_AFFECT_BBOX = {
rect: ['width', 'height', 'lineWidth'],
image: ['width', 'height', 'lineWidth'],
ellipse: ['rx', 'ry', 'lineWidth'],
text: ['fontSize', 'fontWeight'],
text: ['fontSize', 'fontWeight', 'text', 'wordWrapWidth'],
polygon: ['points', 'lineWidth'],
line: ['x1', 'x2', 'y1', 'y2', 'lineWidth'],
polyline: ['points', 'lineWidth'],
path: ['points', 'lineWidth'],
sphere: ['radius'],
cube: ['width', 'height', 'depth'],
plane: ['width', 'depth'],
};
/**
* Will the fields in style affect the bbox.
@ -440,6 +526,9 @@ const FEILDS_AFFECT_BBOX = {
* @param style style object
* @returns
*/
export const isStyleAffectBBox = (type: SHAPE_TYPE, style: ShapeStyle) => {
export const isStyleAffectBBox = (
type: SHAPE_TYPE | SHAPE_TYPE_3D,
style: ShapeStyle,
) => {
return isArrayOverlap(Object.keys(style), FEILDS_AFFECT_BBOX[type]);
};

View File

@ -16,7 +16,11 @@ import {
SHAPE_TYPE_3D,
ShapeStyle,
} from '../types/item';
import { ShapeTagMap, createShape } from './shape';
import {
LOCAL_BOUNDS_DIRTY_FLAG_KEY,
createShape,
isStyleAffectBBox,
} from './shape';
const GeometryTagMap = {
sphere: SphereGeometry,
@ -108,20 +112,17 @@ export const upsertShape3D = (
style: GShapeStyle,
shapeMap: { [shapeId: string]: DisplayObject },
device: any,
): {
updateStyles: ShapeStyle;
shape: DisplayObject;
} => {
): DisplayObject => {
let shape = shapeMap[id];
let updateStyles = {};
if (!shape) {
shape = createShape3D(type, style, id, device);
updateStyles = style;
shape.setAttribute(LOCAL_BOUNDS_DIRTY_FLAG_KEY, true);
} else if (shape.nodeName !== type) {
shape.remove();
shape = createShape3D(type, style, id, device);
updateStyles = style;
shape.setAttribute(LOCAL_BOUNDS_DIRTY_FLAG_KEY, true);
} else {
const updateStyles = {};
const oldStyles = shape.attributes;
Object.keys(style).forEach((key) => {
if (oldStyles[key] !== style[key]) {
@ -129,9 +130,12 @@ export const upsertShape3D = (
shape.style[key] = style[key];
}
});
if (isStyleAffectBBox(type, updateStyles)) {
shape.setAttribute(LOCAL_BOUNDS_DIRTY_FLAG_KEY, true);
}
}
shapeMap[id] = shape;
return { shape, updateStyles };
return shape;
};
/**

View File

@ -0,0 +1,50 @@
import { AABB } from '@antv/g';
import { getEuclideanDistance } from '@antv/layout';
import { Point } from '../types/common';
/**
* Get the proper wordWrapWidth for a labelShape according the the 'maxWidth' of keyShape.
* @param keyShapeBox
* @param maxWidth
* @param zoom
* @returns
*/
export const getWordWrapWidthByBox = (
keyShapeBox: AABB,
maxWidth: string | number,
zoom: number = 1,
) => {
const keyShapeWidth = (keyShapeBox.max[0] - keyShapeBox.min[0]) * zoom;
const wordWrapWidth = 2 * keyShapeWidth;
return getWordWrapWidthWithBase(wordWrapWidth, maxWidth);
};
/**
* Get the proper wordWrapWidth for a labelShape according the the distance between two end points and 'maxWidth'.
* @param points
* @param maxWidth
* @param zoom
* @returns
*/
export const getWordWrapWidthByEnds = (
points: Point[],
maxWidth: string | number,
zoom: number = 1,
) => {
const dist = getEuclideanDistance(points[0], points[1]) * zoom;
return getWordWrapWidthWithBase(dist, maxWidth);
};
const getWordWrapWidthWithBase = (
length: number,
maxWidth: string | number,
) => {
let wordWrapWidth = 2 * length;
if (typeof maxWidth === 'string') {
wordWrapWidth = (length * Number(maxWidth.replace('%', ''))) / 100;
} else if (typeof maxWidth === 'number') {
wordWrapWidth = maxWidth;
}
if (isNaN(wordWrapWidth)) wordWrapWidth = 2 * length;
return wordWrapWidth;
};

View File

@ -0,0 +1,41 @@
import { ZoomStrategy, ZoomStrategyObj } from '../types/item';
/**
* Format zoomStrategy to the pattern that ratio 1 (primary level) at level 0, and higher the ratio, higher the level.
* @param zoomStrategy
* @returns
*/
export const formatZoomStrategy = (
zoomStrategy: ZoomStrategy,
): ZoomStrategyObj => {
const { levels, animateCfg } = zoomStrategy || {};
if (!levels) return undefined;
const primaryLevel = levels.find((level) => level.primary);
const primaryIndex = levels.indexOf(primaryLevel);
const formattedLevels = {};
levels.forEach((level, i) => {
formattedLevels[i - primaryIndex] = level.range;
});
return {
animateCfg,
levels: formattedLevels,
};
};
/**
* Get zoom level idx in levels array.
* @param levels
* @param zoom
* @returns
*/
export const getZoomLevel = (
levels: { [idx: number | string]: [number, number] },
zoom: number,
) => {
let level = 0;
Object.keys(levels).forEach((idx) => {
const range = levels[idx];
if (zoom >= range[0] && zoom < range[1]) level = Number(idx);
});
return level;
};

View File

@ -701,7 +701,7 @@ describe('state', () => {
this.defaultStyles.labelShape,
propsLabelStyle,
);
const labelShape = this.upsertShape(
return this.upsertShape(
'text',
'labelShape',
{
@ -709,8 +709,7 @@ describe('state', () => {
text: model.id,
},
shapeMap,
).shape;
return labelShape;
);
}
public drawOtherShapes(
model: NodeDisplayModel,
@ -728,7 +727,7 @@ describe('state', () => {
y: 0,
},
shapeMap,
).shape,
),
};
}
}
@ -753,7 +752,7 @@ describe('state', () => {
...model.data?.otherShapes?.buShape, // merged style from mappers and states
},
shapeMap,
).shape,
),
};
}
}

View File

@ -8,7 +8,7 @@ describe('node item', () => {
it('new graph with one node', () => {
//done
const nodes = [];
for (let i = 0; i < 8; i++) {
for (let i = 0; i < 100; i++) {
nodes.push({
id: 'node-' + i,
data: {
@ -19,11 +19,11 @@ describe('node item', () => {
});
}
const edges = [];
for (let i = 0; i < 4; i++) {
for (let i = 0; i < 50; i++) {
edges.push({
id: 'edge1-' + i,
source: 'node-' + i,
target: 'node-' + (i + Math.floor(Math.random() * 4)),
target: 'node-' + (i + Math.floor(Math.random() * 50)),
data: {},
});
}
@ -48,6 +48,13 @@ describe('node item', () => {
nodes,
edges,
},
edge: {
type: 'line-edge',
keyShape: {
lineWidth: 2,
stroke: '#000',
},
},
node: {
type: 'sphere-node',
// type: 'circle-node',
@ -55,12 +62,12 @@ describe('node item', () => {
opacity: 0.6,
// materialType: 'basic',
},
labelShape: {
text: 'node-label',
},
iconShape: {
img: 'https://gw.alipayobjects.com/zos/basement_prod/012bcf4f-423b-4922-8c24-32a89f8c41ce.svg',
},
// labelShape: {
// text: 'node-label',
// },
// iconShape: {
// img: 'https://gw.alipayobjects.com/zos/basement_prod/012bcf4f-423b-4922-8c24-32a89f8c41ce.svg',
// },
},
nodeState: {
selected: {

File diff suppressed because it is too large Load Diff

View File

@ -260,7 +260,8 @@ describe('register node', () => {
text: model.id,
},
shapeMap,
).shape;
model,
);
}
public drawOtherShapes(
model: NodeDisplayModel,
@ -278,7 +279,8 @@ describe('register node', () => {
y: 0,
},
shapeMap,
).shape,
model,
),
};
}
}
@ -302,7 +304,8 @@ describe('register node', () => {
fill: '#0f0',
},
shapeMap,
).shape,
model,
),
};
}
}
@ -421,8 +424,13 @@ describe('register node', () => {
this.defaultStyles.keyShape,
model.data.labelShape,
);
return this.upsertShape('rect', 'keyShape', keyShapeStyle, shapeMap)
.shape;
return this.upsertShape(
'rect',
'keyShape',
keyShapeStyle,
shapeMap,
model,
);
}
public drawOtherShapes(
model: NodeDisplayModel,
@ -441,7 +449,8 @@ describe('register node', () => {
lineWidth: 2,
},
shapeMap,
).shape;
model,
);
return { testShape };
}
}
@ -845,7 +854,8 @@ describe('state', () => {
text: model.id,
},
shapeMap,
).shape;
model,
);
}
public drawOtherShapes(
model: NodeDisplayModel,
@ -865,7 +875,8 @@ describe('state', () => {
y: 0,
},
shapeMap,
).shape;
model,
);
return { extraShape };
}
}
@ -890,7 +901,8 @@ describe('state', () => {
...model.data?.otherShapes?.buShape, // merged style from mappers and states
},
shapeMap,
).shape,
model,
),
};
}
}

File diff suppressed because it is too large Load Diff

View File

@ -651,7 +651,8 @@ describe('theme', () => {
text: model.id,
},
shapeMap,
).shape;
model,
);
}
public drawOtherShapes(
model: NodeDisplayModel,
@ -669,7 +670,8 @@ describe('theme', () => {
y: 0,
},
shapeMap,
).shape,
model,
),
};
}
}
@ -693,7 +695,8 @@ describe('theme', () => {
fill: '#0f0',
},
shapeMap,
).shape,
model,
),
};
}
}