使用相机实现视口相关功能 (#4348)

* feat: support translate & zoom with camera #4344

* feat: support fitCenter & focusItem with Camera API #4344

* feat: support fitView #4344

* feat: support focusing multi-items & stop transition of current transform #4348
This commit is contained in:
xiaoiver 2023-03-20 14:53:12 +08:00 committed by GitHub
parent 8f68192dd3
commit 918584d6b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 1133 additions and 177 deletions

View File

@ -2,5 +2,6 @@ export * from './data';
export * from './interaction';
export * from './item';
export * from './layout';
export * from './viewport';
export * from './theme';
export * from './extension';

View File

@ -1,18 +1,17 @@
import { AABB, Canvas, DisplayObject, Group } from '@antv/g';
import { GraphChange, ID } from '@antv/graphlib';
import { Group, AABB, DisplayObject, Canvas } from '@antv/g';
import { ComboModel, IGraph } from '../../types';
import registry from '../../stdlib';
import { getExtension } from '../../util/extension';
import { GraphCore } from '../../types/data';
import { NodeDisplayModel, NodeEncode, NodeModel, NodeModelData } from '../../types/node';
import { EdgeDisplayModel, EdgeEncode, EdgeModel, EdgeModelData } from '../../types/edge';
import Node from '../../item/node';
import Edge from '../../item/edge';
import Combo from '../../item/combo';
import { ThemeSpecification, ItemThemeSpecifications, ItemStyleSet } from '../../types/theme';
import { isArray, isObject } from '@antv/util';
import { ITEM_TYPE, SHAPE_TYPE, ShapeStyle } from '../../types/item';
import Combo from '../../item/combo';
import Edge from '../../item/edge';
import Node from '../../item/node';
import registry from '../../stdlib';
import { ComboModel, IGraph } from '../../types';
import { ComboDisplayModel, ComboEncode } from '../../types/combo';
import { GraphCore } from '../../types/data';
import { EdgeDisplayModel, EdgeEncode, EdgeModel, EdgeModelData } from '../../types/edge';
import { ITEM_TYPE, ShapeStyle, SHAPE_TYPE } from '../../types/item';
import { ItemStyleSet, ItemThemeSpecifications, ThemeSpecification } from '../../types/theme';
import { getExtension } from '../../util/extension';
import { upsertShape } from '../../util/shape';
/**
@ -61,7 +60,7 @@ export class ItemController {
constructor(graph: IGraph<any, any>) {
this.graph = graph;
// get mapper for node / edge / combo
const { node, edge, combo, nodeState, edgeState, comboState } = graph.getSpecification();
const { node, edge, combo, nodeState, edgeState, comboState } = graph.getSpecification();
this.nodeMapper = node;
this.edgeMapper = edge;
this.comboMapper = combo;
@ -114,7 +113,7 @@ export class ItemController {
* Listener of runtime's render hook.
* @param param contains inner data stored in graphCore structure
*/
private onRender(param: { graphCore: GraphCore, theme: ThemeSpecification }) {
private onRender(param: { graphCore: GraphCore; theme: ThemeSpecification }) {
const { graphCore, theme = {} } = param;
const { graph } = this;
// TODO: 0. clear groups on canvas, and create new groups
@ -177,11 +176,17 @@ export class ItemController {
// === 3. add nodes ===
if (groupedChanges.NodeAdded.length) {
this.renderNodes(groupedChanges.NodeAdded.map((change) => change.value), nodeTheme);
this.renderNodes(
groupedChanges.NodeAdded.map((change) => change.value),
nodeTheme,
);
}
// === 4. add edges ===
if (groupedChanges.EdgeAdded.length) {
this.renderEdges(groupedChanges.EdgeAdded.map((change) => change.value), edgeTheme);
this.renderEdges(
groupedChanges.EdgeAdded.map((change) => change.value),
edgeTheme,
);
}
// === 5. update nodes's data ===
@ -209,7 +214,12 @@ export class ItemController {
// update the theme if the dataType value is changed
let themeStyles;
if (previous[nodeDataTypeField] !== current[nodeDataTypeField]) {
themeStyles = getThemeStyles(this.nodeDataTypeSet, nodeDataTypeField, current[nodeDataTypeField], nodeTheme);
themeStyles = getThemeStyles(
this.nodeDataTypeSet,
nodeDataTypeField,
current[nodeDataTypeField],
nodeTheme,
);
}
const item = itemMap[id];
const innerModel = graphCore.getNode(id);
@ -247,7 +257,12 @@ export class ItemController {
// update the theme if the dataType value is changed
let themeStyles;
if (previous[edgeDataTypeField] !== current[edgeDataTypeField]) {
themeStyles = getThemeStyles(this.edgeDataTypeSet, edgeDataTypeField, current[edgeDataTypeField], edgeTheme);
themeStyles = getThemeStyles(
this.edgeDataTypeSet,
edgeDataTypeField,
current[edgeDataTypeField],
edgeTheme,
);
}
const item = itemMap[id];
const innerModel = graphCore.getEdge(id);
@ -283,9 +298,9 @@ export class ItemController {
* value: state value
* }
*/
private onItemStateChange(param: { ids: ID[], states: string[], value: boolean }) {
private onItemStateChange(param: { ids: ID[]; states: string[]; value: boolean }) {
const { ids, states, value } = param;
ids.forEach(id => {
ids.forEach((id) => {
const item = this.itemMap[id];
if (!item) {
console.warn(`Fail to set state for item ${id}, which is not exist.`);
@ -295,20 +310,20 @@ export class ItemController {
// clear all the states
item.clearStates(states);
} else {
states.forEach(state => item.setState(state, value));
states.forEach((state) => item.setState(state, value));
}
});
}
private onTransientUpdate(param: {
type: ITEM_TYPE | SHAPE_TYPE,
id: ID,
type: ITEM_TYPE | SHAPE_TYPE;
id: ID;
config: {
style: ShapeStyle,
action: 'remove' | 'add' | 'update' | undefined,
[shapeConfig: string]: unknown,
},
canvas: Canvas
style: ShapeStyle;
action: 'remove' | 'add' | 'update' | undefined;
[shapeConfig: string]: unknown;
};
canvas: Canvas;
}) {
const { transientMap } = this;
const { type, id, config = {}, canvas } = param;
@ -345,7 +360,7 @@ export class ItemController {
let dataType;
if (dataTypeField) dataType = node.data[dataTypeField] as string;
const themeStyle = getThemeStyles(nodeDataTypeSet, dataTypeField, dataType, nodeTheme);
this.itemMap[node.id] = new Node({
model: node,
renderExtensions: nodeExtensions,
@ -405,7 +420,7 @@ export class ItemController {
*/
public findIdByState(itemType: ITEM_TYPE, state: string, value: string | boolean = true) {
const ids = [];
Object.values(this.itemMap).forEach(item => {
Object.values(this.itemMap).forEach((item) => {
if (item.getType() !== itemType) return;
if (item.hasState(state) === value) ids.push(item.getID());
});
@ -427,6 +442,10 @@ export class ItemController {
return item.hasState(state);
}
public getItemById(id: ID) {
return this.itemMap[id];
}
public getItemBBox(id: ID, isKeyShape: boolean = false): AABB | false {
const item = this.itemMap[id];
if (!item) {
@ -446,7 +465,12 @@ export class ItemController {
}
}
const getThemeStyles = (dataTypeSet: Set<string>, dataTypeField: string, dataType: string, itemTheme: ItemThemeSpecifications): ItemStyleSet => {
const getThemeStyles = (
dataTypeSet: Set<string>,
dataTypeField: string,
dataType: string,
itemTheme: ItemThemeSpecifications,
): ItemStyleSet => {
const { styles: themeStyles } = itemTheme;
if (!dataTypeField) {
// dataType field is not assigned
@ -462,4 +486,4 @@ const getThemeStyles = (dataTypeSet: Set<string>, dataTypeField: string, dataTyp
themeStyle = themeStyles[dataType] || themeStyles.others;
}
return themeStyle;
}
};

View File

@ -0,0 +1,92 @@
import { ICamera, PointLike } from '@antv/g';
import { IGraph } from '../../types';
import { CameraAnimationOptions } from '../../types/animate';
import { ViewportChangeHookParams } from '../../types/hook';
let landmarkCounter = 0;
export class ViewportController {
public graph: IGraph;
constructor(graph: IGraph<any>) {
this.graph = graph;
this.tap();
}
/**
* Subscribe the lifecycle of graph.
*/
private tap() {
this.graph.hooks.viewportchange.tap(this.onViewportChange.bind(this));
}
private async onViewportChange({ transform, effectTiming }: ViewportChangeHookParams) {
const camera = this.graph.canvas.getCamera();
const { translate, rotate, zoom, origin = this.graph.getViewportCenter() } = transform;
const currentZoom = camera.getZoom();
if (effectTiming) {
const { duration = 1000, easing = 'linear', easingFunction } = effectTiming;
const landmarkOptions: Partial<{
position: [number, number] | [number, number, number] | Float32Array;
focalPoint: [number, number] | [number, number, number] | Float32Array;
zoom: number;
roll: number;
}> = {};
if (translate) {
const { dx = 0, dy = 0 } = translate;
const [px, py] = camera.getPosition();
const [fx, fy] = camera.getFocalPoint();
landmarkOptions.position = [px - dx, py - dy];
landmarkOptions.focalPoint = [fx - dx, fy - dy];
}
if (zoom) {
const { ratio } = zoom;
landmarkOptions.zoom = currentZoom * ratio;
}
if (rotate) {
const { angle } = rotate;
landmarkOptions.roll = camera.getRoll() + angle;
}
const landmark = camera.createLandmark(`mark${landmarkCounter++}`, landmarkOptions);
return new Promise((resolve) => {
camera.gotoLandmark(landmark, {
duration: Number(duration),
easing,
easingFunction,
onfinish: () => {
resolve(undefined);
},
});
});
} else {
if (translate) {
const { dx = 0, dy = 0 } = translate;
camera.pan(-dx / currentZoom, -dy / currentZoom);
}
if (rotate) {
const { angle } = rotate;
const [x, y] = camera.getPosition();
if (origin) {
camera.setPosition(origin.x, origin.y);
}
camera.rotate(0, 0, angle);
if (origin) {
camera.pan(x - origin.x, y - origin.y);
}
}
if (zoom) {
const { ratio } = zoom;
camera.setZoomByViewportPoint(currentZoom * ratio, [origin.x, origin.y]);
}
}
}
destroy() {}
}

View File

@ -1,5 +1,5 @@
import EventEmitter from '@antv/event-emitter';
import { Canvas, runtime, AABB, DisplayObject } from '@antv/g';
import { AABB, Canvas, DisplayObject, PointLike, runtime } from '@antv/g';
import { GraphChange, ID } from '@antv/graphlib';
import { isArray, isNil, isNumber, isObject, isString } from '@antv/util';
import {
@ -10,13 +10,13 @@ import {
NodeUserModel,
Specification,
} from '../types';
import { AnimateCfg } from '../types/animate';
import { CameraAnimationOptions } from '../types/animate';
import { BehaviorObjectOptionsOf, BehaviorOptionsOf, BehaviorRegistry } from '../types/behavior';
import { ComboModel } from '../types/combo';
import { Padding, Point } from '../types/common';
import { Padding } from '../types/common';
import { GraphCore } from '../types/data';
import { EdgeModel, EdgeModelData } from '../types/edge';
import { Hooks } from '../types/hook';
import { Hooks, ViewportChangeHookParams } from '../types/hook';
import { ITEM_TYPE, ShapeStyle, SHAPE_TYPE } from '../types/item';
import {
ImmediatelyInvokedLayoutOptions,
@ -25,8 +25,9 @@ import {
} from '../types/layout';
import { NodeModel, NodeModelData } from '../types/node';
import { ThemeRegistry, ThemeSpecification } from '../types/theme';
import { FitViewRules, GraphAlignment } from '../types/view';
import { FitViewRules, GraphTransformOptions } from '../types/view';
import { createCanvas } from '../util/canvas';
import { formatPadding } from '../util/shape';
import {
DataController,
ExtensionController,
@ -34,6 +35,7 @@ import {
ItemController,
LayoutController,
ThemeController,
ViewportController,
} from './controller';
import Hook from './hooks';
@ -42,7 +44,10 @@ import Hook from './hooks';
*/
runtime.enableCSSParsing = false;
export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry> extends EventEmitter implements IGraph<B, T> {
export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
extends EventEmitter
implements IGraph<B, T>
{
public hooks: Hooks;
// for nodes and edges, which will be separate into groups
public canvas: Canvas;
@ -58,6 +63,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
private dataController: DataController;
private interactionController: InteractionController;
private layoutController: LayoutController;
private viewportController: ViewportController;
private itemController: ItemController;
private extensionController: ExtensionController;
private themeController: ThemeController;
@ -65,9 +71,9 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
private defaultSpecification = {
theme: {
type: 'spec',
base: 'light'
}
}
base: 'light',
},
};
constructor(spec: Specification<B, T>) {
super();
@ -82,8 +88,8 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
canvases: {
background: this.backgroundCanvas,
main: this.canvas,
transient: this.transientCanvas
}
transient: this.transientCanvas,
},
});
const { data } = spec;
@ -102,6 +108,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
this.layoutController = new LayoutController(this);
this.themeController = new ThemeController(this);
this.itemController = new ItemController(this);
this.viewportController = new ViewportController(this);
this.extensionController = new ExtensionController(this);
}
@ -132,10 +139,10 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
this.hooks = {
init: new Hook<{
canvases: {
background: Canvas,
main: Canvas,
transient: Canvas
}
background: Canvas;
main: Canvas;
transient: Canvas;
};
}>({ name: 'init' }),
datachange: new Hook<{ data: GraphData; type: 'replace' }>({ name: 'datachange' }),
itemchange: new Hook<{
@ -144,16 +151,24 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
graphCore: GraphCore;
theme: ThemeSpecification;
}>({ name: 'itemchange' }),
render: new Hook<{ graphCore: GraphCore, theme: ThemeSpecification; }>({ name: 'render' }),
render: new Hook<{ graphCore: GraphCore; theme: ThemeSpecification }>({ name: 'render' }),
layout: new Hook<{ graphCore: GraphCore }>({ name: 'layout' }),
viewportchange: new Hook<ViewportChangeHookParams>({ name: 'viewport' }),
modechange: new Hook<{ mode: string }>({ name: 'modechange' }),
behaviorchange: new Hook<{
action: 'update' | 'add' | 'remove';
modes: string[];
behaviors: BehaviorOptionsOf<{}>[];
}>({ name: 'behaviorchange' }),
itemstatechange: new Hook<{ ids: ID[], state: string, value: boolean }>({ name: 'itemstatechange' }),
transientupdate: new Hook<{ type: ITEM_TYPE | SHAPE_TYPE, id: ID, config: { style: ShapeStyle, action: 'remove' | 'add' | 'update' | undefined }, canvas: Canvas }>({ name: 'transientupdate'}), // TODO
itemstatechange: new Hook<{ ids: ID[]; state: string; value: boolean }>({
name: 'itemstatechange',
}),
transientupdate: new Hook<{
type: ITEM_TYPE | SHAPE_TYPE;
id: ID;
config: { style: ShapeStyle; action: 'remove' | 'add' | 'update' | undefined };
canvas: Canvas;
}>({ name: 'transientupdate' }), // TODO
};
}
@ -184,7 +199,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
const emitRender = async () => {
this.hooks.render.emit({
graphCore: this.dataController.graphCore,
theme: this.themeController.specification
theme: this.themeController.specification,
});
this.emit('afterrender');
@ -211,7 +226,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
this.hooks.datachange.emit({ data, type });
this.hooks.render.emit({
graphCore: this.dataController.graphCore,
theme: this.themeController.specification
theme: this.themeController.specification,
});
this.emit('afterrender');
@ -226,84 +241,223 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
// TODO
}
/**
* Move the graph with a relative vector.
* @param dx x of the relative vector
* @param dy y of the relative vector
* @param animateCfg animation configurations
* @returns
* @group View
*/
public move(dx: number, dy: number, animateCfg?: AnimateCfg) {
// TODO
public getViewportCenter(): PointLike {
const { width, height } = this.canvas.getConfig();
return { x: width! / 2, y: height! / 2 };
}
public async transform(
options: GraphTransformOptions,
effectTiming?: CameraAnimationOptions,
): Promise<void> {
await this.hooks.viewportchange.emitLinearAsync({
transform: options,
effectTiming,
});
this.emit('viewportchange', options);
}
/**
* Move the graph and align to a point.
* @param x position on the canvas to align
* @param y position on the canvas to align
* @param alignment alignment of the graph content
* @param animateCfg animation configurations
* @returns
* @group View
* Stop the current transition of transform immediately.
*/
public moveTo(x: number, y: number, alignment: GraphAlignment, animateCfg?: AnimateCfg) {
// TODO
public stopTransformTransition() {
this.canvas.getCamera().cancelLandmarkAnimation();
}
/**
* Move the graph with a relative distance under viewport coordinates.
* @param dx x of the relative distance
* @param dy y of the relative distance
* @param effectTiming animation configurations
*/
public async translate(dx: number, dy: number, effectTiming?: CameraAnimationOptions) {
await this.transform(
{
translate: {
dx,
dy,
},
},
effectTiming,
);
}
/**
* Move the graph to destination under viewport coordinates.
* @param destination destination under viewport coordinates.
* @param effectTiming animation configurations
*/
public async translateTo({ x, y }: PointLike, effectTiming?: CameraAnimationOptions) {
const { x: cx, y: cy } = this.getViewportCenter();
await this.translate(cx - x, cy - y, effectTiming);
}
/**
* Zoom the graph with a relative ratio.
* @param ratio relative ratio to zoom
* @param center zoom center
* @param animateCfg animation configurations
* @returns
* @group View
* @param origin origin under viewport coordinates.
* @param effectTiming animation configurations
*/
public zoom(ratio: number, center?: Point, animateCfg?: AnimateCfg) {
// TODO
public async zoom(ratio: number, origin?: PointLike, effectTiming?: CameraAnimationOptions) {
await this.transform(
{
zoom: {
ratio,
},
origin,
},
effectTiming,
);
}
/**
* Zoom the graph to a specified ratio.
* @param toRatio specified ratio
* @param center zoom center
* @param animateCfg animation configurations
* @returns
* @group View
* @param zoom specified ratio
* @param origin zoom center
* @param effectTiming animation configurations
*/
public zoomTo(toRatio: number, center?: Point, animateCfg?: AnimateCfg) {
// TODO
public async zoomTo(zoom: number, origin?: PointLike, effectTiming?: CameraAnimationOptions) {
await this.zoom(zoom / this.canvas.getCamera().getZoom(), origin, effectTiming);
}
/**
* Return the current zoom level of camera.
* @returns current zoom
*/
public getZoom() {
return this.canvas.getCamera().getZoom();
}
/**
* Rotate the graph with a relative angle.
* @param angle
* @param origin
* @param effectTiming
*/
public async rotate(angle: number, origin?: PointLike, effectTiming?: CameraAnimationOptions) {
await this.transform(
{
rotate: {
angle,
},
origin,
},
effectTiming,
);
}
/**
* Rotate the graph to an absolute angle.
* @param angle
* @param origin
* @param effectTiming
*/
public async rotateTo(angle: number, origin?: PointLike, effectTiming?: CameraAnimationOptions) {
await this.rotate(angle - this.canvas.getCamera().getRoll(), origin, effectTiming);
}
/**
* Fit the graph content to the view.
* @param padding padding while fitting
* @param rules rules for fitting
* @param animateCfg animation configurations
* @returns
* @group View
* @param options.padding padding while fitting
* @param options.rules rules for fitting
* @param effectTiming animation configurations
*/
public fitView(padding?: Padding, rules?: FitViewRules, animateCfg?: AnimateCfg) {
// TODO
public async fitView(
options?: {
padding: Padding;
rules: FitViewRules;
},
effectTiming?: CameraAnimationOptions,
) {
const { padding, rules } = options || {};
const [top, right, bottom, left] = padding ? formatPadding(padding) : [0, 0, 0, 0];
const { direction = 'both', ratioRule = 'min' } = rules || {};
// Get the bounds of the whole graph.
const {
center: [graphCenterX, graphCenterY],
halfExtents,
} = this.canvas.document.documentElement.getBounds();
const origin = this.canvas.canvas2Viewport({ x: graphCenterX, y: graphCenterY });
const { width: viewportWidth, height: viewportHeight } = this.canvas.getConfig();
const graphWidth = halfExtents[0] * 2;
const graphHeight = halfExtents[1] * 2;
const tlInCanvas = this.canvas.viewport2Canvas({ x: left, y: top });
const brInCanvas = this.canvas.viewport2Canvas({
x: viewportWidth! - right,
y: viewportHeight! - bottom,
});
const targetViewWidth = brInCanvas.x - tlInCanvas.x;
const targetViewHeight = brInCanvas.y - tlInCanvas.y;
const wRatio = targetViewWidth / graphWidth;
const hRatio = targetViewHeight / graphHeight;
let ratio: number;
if (direction === 'x') {
ratio = wRatio;
} else if (direction === 'y') {
ratio = hRatio;
} else {
ratio = ratioRule === 'max' ? Math.max(wRatio, hRatio) : Math.min(wRatio, hRatio);
}
await this.transform(
{
translate: {
dx: viewportWidth! / 2 - origin.x,
dy: viewportHeight! / 2 - origin.y,
},
zoom: {
ratio,
},
},
effectTiming,
);
}
/**
* Fit the graph center to the view center.
* @param animateCfg animation configurations
* @returns
* @group View
* @param effectTiming animation configurations
*/
public fitCenter(animateCfg?: AnimateCfg) {
// TODO
public async fitCenter(effectTiming?: CameraAnimationOptions) {
// Get the bounds of the whole graph.
const {
center: [graphCenterX, graphCenterY],
} = this.canvas.document.documentElement.getBounds();
await this.translateTo(
this.canvas.canvas2Viewport({ x: graphCenterX, y: graphCenterY }),
effectTiming,
);
}
/**
* Move the graph to make the item align the view center.
* @param item node/edge/combo item or its id
* @param animateCfg animation configurations
* @returns
* @group View
* @param effectTiming animation configurations
*/
public focusItem(ids: ID | ID[], animateCfg?: AnimateCfg) {
// TODO
public async focusItem(id: ID | ID[], effectTiming?: CameraAnimationOptions) {
let bounds: AABB | null = null;
for (const itemId of !isArray(id) ? [id] : id) {
const item = this.itemController.getItemById(itemId);
if (item) {
const itemBounds = item.group.getBounds();
if (!bounds) {
bounds = itemBounds;
} else {
bounds.add(itemBounds);
}
}
}
if (bounds) {
const {
center: [itemCenterX, itemCenterY],
} = bounds;
await this.translateTo(
this.canvas.canvas2Viewport({ x: itemCenterX, y: itemCenterY }),
effectTiming,
);
}
}
// ===== item operations =====
@ -371,7 +525,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
public getRelatedEdgesData(nodeId: ID, direction: 'in' | 'out' | 'both' = 'both'): EdgeModel[] {
return this.dataController.findRelatedEdgeIds(nodeId, direction);
}
/**
/**
* Get one-hop node ids from a start node.
* @param nodeId id of the start node
* @returns one-hop node ids
@ -380,7 +534,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
public getNeighborNodesData(nodeId: ID, direction: 'in' | 'out' | 'both' = 'both'): NodeModel[] {
return this.dataController.findNeighborNodeIds(nodeId, direction);
}
/**
* Find items which has the state.
* @param itemType item type
@ -430,7 +584,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
type: itemType,
changes: graphCore.reduceChanges(event.changes),
graphCore,
theme: specification
theme: specification,
});
});
@ -466,7 +620,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
type: itemType,
changes: event.changes,
graphCore,
theme: specification
theme: specification,
});
});
this.hooks.datachange.emit({
@ -505,7 +659,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
type: itemType,
changes: event.changes,
graphCore,
theme: specification
theme: specification,
});
});
@ -587,7 +741,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
* @returns rendering bounding box. returns false if the item is not exist
* @group Item
*/
public getRenderBBox(id: ID | undefined): AABB | false{
public getRenderBBox(id: ID | undefined): AABB | false {
if (!id) return this.canvas.getRoot().getRenderBounds();
return this.itemController.getItemBBox(id);
}
@ -769,7 +923,11 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
* @returns upserted shape or group
* @group Interaction
*/
public drawTransient(type: ITEM_TYPE | SHAPE_TYPE, id: ID, config: { action: 'remove' | 'add' | 'update' | undefined, style: ShapeStyle}): DisplayObject {
public drawTransient(
type: ITEM_TYPE | SHAPE_TYPE,
id: ID,
config: { action: 'remove' | 'add' | 'update' | undefined; style: ShapeStyle },
): DisplayObject {
this.hooks.transientupdate.emit({ type, id, config, canvas: this.transientCanvas });
return this.itemController.getTransient(String(id));
}

View File

@ -1,3 +1,5 @@
import { IAnimationEffectTiming } from '@antv/g';
export interface AnimateCfg {
/**
* Whether enable animation.
@ -39,7 +41,7 @@ export interface AnimateCfg {
* @type {function}
*/
resumeCallback?: () => void;
};
}
export type AnimateWhen = 'show' | 'exit' | 'update' | 'last';
@ -47,4 +49,7 @@ export interface AnimateAttr {
when: AnimateWhen;
type: string;
[param: string]: unknown;
}
}
export interface CameraAnimationOptions
extends Pick<IAnimationEffectTiming, 'duration' | 'easing' | 'easingFunction'> {}

View File

@ -1,21 +1,24 @@
import EventEmitter from '@antv/event-emitter';
import { Canvas, AABB, DisplayObject } from '@antv/g';
import { AABB, Canvas, DisplayObject, PointLike } from '@antv/g';
import { ID } from '@antv/graphlib';
import { Hooks } from '../types/hook';
import { AnimateCfg } from './animate';
import { CameraAnimationOptions } from './animate';
import { BehaviorObjectOptionsOf, BehaviorOptionsOf, BehaviorRegistry } from './behavior';
import { ComboModel, ComboUserModel } from './combo';
import { Padding, Point } from './common';
import { DataChangeType, GraphData } from './data';
import { GraphData } from './data';
import { EdgeModel, EdgeUserModel } from './edge';
import { ITEM_TYPE, SHAPE_TYPE } from './item';
import { LayoutOptions } from './layout';
import { NodeModel, NodeUserModel } from './node';
import { Specification } from './spec';
import { ThemeRegistry } from './theme';
import { FitViewRules, GraphAlignment } from './view';
import { FitViewRules, GraphTransformOptions } from './view';
export interface IGraph<B extends BehaviorRegistry = BehaviorRegistry, T extends ThemeRegistry = ThemeRegistry> extends EventEmitter {
export interface IGraph<
B extends BehaviorRegistry = BehaviorRegistry,
T extends ThemeRegistry = ThemeRegistry,
> extends EventEmitter {
hooks: Hooks;
canvas: Canvas;
destroyed: boolean;
@ -182,63 +185,98 @@ export interface IGraph<B extends BehaviorRegistry = BehaviorRegistry, T extends
* Move the graph with a relative vector.
* @param dx x of the relative vector
* @param dy y of the relative vector
* @param animateCfg animation configurations
* @returns
* @group View
* @param effectTiming animation configurations
*/
move: (dx: number, dy: number, animateCfg?: AnimateCfg) => void;
translate: (dx: number, dy: number, effectTiming?: CameraAnimationOptions) => Promise<void>;
/**
* Move the graph and align to a point.
* @param x position on the canvas to align
* @param y position on the canvas to align
* @param alignment alignment of the graph content
* @param animateCfg animation configurations
* @returns
* @group View
* @param point position on the canvas to align
* @param effectTiming animation configurations
*/
moveTo: (x: number, y: number, alignment: GraphAlignment, animateCfg?: AnimateCfg) => void;
translateTo: (point: PointLike, effectTiming?: CameraAnimationOptions) => Promise<void>;
/**
* Return the current zoom level of camera.
* @returns current zoom
*/
getZoom: () => number;
/**
* Zoom the graph with a relative ratio.
* @param ratio relative ratio to zoom
* @param center zoom center
* @param animateCfg animation configurations
* @returns
* @group View
* @param effectTiming animation configurations
*/
zoom: (ratio: number, center?: Point, animateCfg?: AnimateCfg) => void;
zoom: (ratio: number, center?: Point, effectTiming?: CameraAnimationOptions) => Promise<void>;
/**
* Zoom the graph to a specified ratio.
* @param toRatio specified ratio
* @param center zoom center
* @param animateCfg animation configurations
* @returns
* @group View
* @param effectTiming animation configurations
*/
zoomTo: (toRatio: number, center?: Point, animateCfg?: AnimateCfg) => void;
zoomTo: (toRatio: number, center?: Point, effectTiming?: CameraAnimationOptions) => Promise<void>;
/**
* Rotate the graph with a relative angle in clockwise.
* @param angle
* @param center
* @param effectTiming
*/
rotate: (angle: number, center?: Point, effectTiming?: CameraAnimationOptions) => Promise<void>;
/**
* Rotate the graph to an absolute angle in clockwise.
* @param toAngle
* @param center
* @param effectTiming
*/
rotateTo: (
toAngle: number,
center?: Point,
effectTiming?: CameraAnimationOptions,
) => Promise<void>;
/**
* Transform the graph with a CSS-Transform-like syntax.
* @param options
* @param effectTiming
*/
transform: (
options: GraphTransformOptions,
effectTiming?: CameraAnimationOptions,
) => Promise<void>;
/**
* Stop the current transition of transform immediately.
*/
stopTransformTransition: () => void;
/**
* Return the center of viewport, e.g. for a 500 * 500 canvas, its center is [250, 250].
*/
getViewportCenter: () => PointLike;
/**
* Fit the graph content to the view.
* @param padding padding while fitting
* @param rules rules for fitting
* @param animateCfg animation configurations
* @param options.padding padding while fitting
* @param options.rules rules for fitting
* @param effectTiming animation configurations
* @returns
* @group View
*/
fitView: (padding?: Padding, rules?: FitViewRules, animateCfg?: AnimateCfg) => void;
fitView: (
options?: {
padding: Padding;
rules: FitViewRules;
},
effectTiming?: CameraAnimationOptions,
) => Promise<void>;
/**
* Fit the graph center to the view center.
* @param animateCfg animation configurations
* @param effectTiming animation configurations
* @returns
* @group View
*/
fitCenter: (animateCfg?: AnimateCfg) => void;
fitCenter: (effectTiming?: CameraAnimationOptions) => Promise<void>;
/**
* Move the graph to make the item align the view center.
* @param item node/edge/combo item or its id
* @param animateCfg animation configurations
* @returns
* @group View
* @param effectTiming animation configurations
*/
focusItem: (ids: ID | ID[], animateCfg?: AnimateCfg) => void;
focusItem: (id: ID | ID[], effectTiming?: CameraAnimationOptions) => Promise<void>;
// ===== item operations =====
/**

View File

@ -1,11 +1,13 @@
import { Canvas } from '@antv/g';
import { DataChangeType, GraphCore, GraphData } from "./data";
import { NodeModelData } from "./node";
import { EdgeModelData } from "./edge";
import { ITEM_TYPE, ShapeStyle, SHAPE_TYPE } from "./item";
import { GraphChange, ID } from "@antv/graphlib";
import { LayoutOptions } from "./layout";
import { ThemeSpecification } from "./theme";
import { GraphChange, ID } from '@antv/graphlib';
import { CameraAnimationOptions } from './animate';
import { DataChangeType, GraphCore, GraphData } from './data';
import { EdgeModelData } from './edge';
import { ITEM_TYPE, ShapeStyle, SHAPE_TYPE } from './item';
import { LayoutOptions } from './layout';
import { NodeModelData } from './node';
import { ThemeSpecification } from './theme';
import { GraphTransformOptions } from './view';
export interface IHook<T> {
name: string;
@ -16,18 +18,23 @@ export interface IHook<T> {
emitLinearAsync: (param: T) => Promise<void>;
}
export type ViewportChangeHookParams = {
transform: GraphTransformOptions;
effectTiming?: CameraAnimationOptions;
};
export interface Hooks {
init: IHook<{
canvases: {
background: Canvas,
main: Canvas,
transient: Canvas
}
background: Canvas;
main: Canvas;
transient: Canvas;
};
}>;
// data
datachange: IHook<{
type: DataChangeType;
data: GraphData
data: GraphData;
}>;
itemchange: IHook<{
type: ITEM_TYPE;
@ -35,7 +42,7 @@ export interface Hooks {
graphCore: GraphCore;
theme: ThemeSpecification;
}>;
render: IHook<{ graphCore: GraphCore, theme: ThemeSpecification }>; // TODO: define param template
render: IHook<{ graphCore: GraphCore; theme: ThemeSpecification }>; // TODO: define param template
layout: IHook<{ graphCore: GraphCore; options?: LayoutOptions }>; // TODO: define param template
// 'updatelayout': IHook<any>; // TODO: define param template
modechange: IHook<{ mode: string }>;
@ -54,12 +61,12 @@ export interface Hooks {
id: ID;
canvas: Canvas;
config: {
style: ShapeStyle,
action: 'remove' | 'add' | 'update' | undefined
style: ShapeStyle;
action: 'remove' | 'add' | 'update' | undefined;
};
}>;
// TODO: define param template
// 'viewportchange': IHook<any>; // TODO: define param template
viewportchange: IHook<ViewportChangeHookParams>;
// 'destroy': IHook<any>; // TODO: define param template
// TODO: more timecycles here
}

View File

@ -4,4 +4,27 @@ export interface FitViewRules {
ratioRule?: 'max' | 'min'; // Ratio rule to fit.
}
export type GraphAlignment = 'left-top' | 'right-top' | 'left-bottom' | 'right-bottom' | 'center' | [number, number];
export type GraphAlignment =
| 'left-top'
| 'right-top'
| 'left-bottom'
| 'right-bottom'
| 'center'
| [number, number];
export type GraphTransformOptions = {
translate?: {
dx: number;
dy: number;
};
rotate?: {
angle: number;
};
zoom?: {
ratio: number;
};
origin?: {
x: number;
y: number;
};
};

View File

@ -140,7 +140,7 @@ export const formatPadding = (value, defaultArr = DEFAULT_LABEL_BG_PADDING) => {
/**
* Merge multiple shape style map including undefined value in incoming map.
* @param styleMaps shapes' styles map array, the latter item in the array will be merged into the former
* @returns
* @returns
*/
export const mergeStyles = (styleMaps: ItemShapeStyles[]) => {
let currentResult = styleMaps[0];
@ -148,29 +148,29 @@ export const mergeStyles = (styleMaps: ItemShapeStyles[]) => {
if (i > 0) currentResult = merge2Styles(currentResult, styleMap);
});
return currentResult;
}
};
/**
* Merge two shape style map including undefined value in incoming map.
* @param styleMap1 shapes' styles map as current map
* @param styleMap2 shapes' styles map as incoming map
* @returns
* @returns
*/
const merge2Styles = (styleMap1: ItemShapeStyles, styleMap2: ItemShapeStyles) => {
if (!styleMap1) return clone(styleMap2);
else if (!styleMap2) return clone(styleMap1);
const mergedStyle = clone(styleMap1);
Object.keys(styleMap2).forEach(shapeId => {
Object.keys(styleMap2).forEach((shapeId) => {
const style = styleMap2[shapeId];
mergedStyle[shapeId] = mergedStyle[shapeId] || {};
if (!style) return;
Object.keys(style).forEach(styleName => {
Object.keys(style).forEach((styleName) => {
const value = style[styleName];
mergedStyle[shapeId][styleName] = value;
});
});
return mergedStyle;
}
};
/**
* Whether two polygons are intersected.
@ -179,10 +179,10 @@ const merge2Styles = (styleMap1: ItemShapeStyles, styleMap2: ItemShapeStyles) =>
*/
export const isPolygonsIntersect = (points1: number[][], points2: number[][]): boolean => {
const getBBox = (points): Partial<AABB> => {
const xArr = points.map(p => p[0]);
const yArr = points.map(p => p[1]);
const xArr = points.map((p) => p[0]);
const yArr = points.map((p) => p[1]);
return {
min: [Math.min.apply(null, xArr), Math.min.apply(null, yArr), 0],
min: [Math.min.apply(null, xArr), Math.min.apply(null, yArr), 0],
max: [Math.max.apply(null, xArr), Math.max.apply(null, yArr), 0],
};
};
@ -235,7 +235,7 @@ export const isPolygonsIntersect = (points1: number[][], points2: number[][]): b
let isIn = false;
// 判定点是否在多边形内部,一旦有一个点在另一个多边形内,则返回
points2.forEach(point => {
points2.forEach((point) => {
if (isPointInPolygon(points1, point[0], point[1])) {
isIn = true;
return false;
@ -244,7 +244,7 @@ export const isPolygonsIntersect = (points1: number[][], points2: number[][]): b
if (isIn) {
return true;
}
points1.forEach(point => {
points1.forEach((point) => {
if (isPointInPolygon(points2, point[0], point[1])) {
isIn = true;
return false;
@ -257,7 +257,7 @@ export const isPolygonsIntersect = (points1: number[][], points2: number[][]): b
const lines1 = parseToLines(points1);
const lines2 = parseToLines(points2);
let isIntersect = false;
lines2.forEach(line => {
lines2.forEach((line) => {
if (lineIntersectPolygon(lines1, line)) {
isIntersect = true;
return false;
@ -276,7 +276,7 @@ export const intersectBBox = (box1: Partial<AABB>, box2: Partial<AABB>) => {
};
/**
* Whether point is inside the polygon (ray algo)
* Whether point is inside the polygon (ray algo)
* @param points
* @param x
* @param y
@ -320,7 +320,7 @@ export const isPointInPolygon = (points: number[][], x: number, y: number) => {
* @param p1 begin of segment line
* @param p2 end of segment line
* @param q the point to be judged
* @returns
* @returns
*/
const onSegment = (p1, p2, q) => {
if (
@ -333,11 +333,11 @@ const onSegment = (p1, p2, q) => {
return true;
}
return false;
}
};
const lineIntersectPolygon = (lines, line) => {
let isIntersect = false;
lines.forEach(l => {
lines.forEach((l) => {
if (getLineIntersect(l.from, l.to, line.from, line.to)) {
isIntersect = true;
return false;

View File

@ -0,0 +1,608 @@
import { Graph, Layout, LayoutMapping } from '@antv/layout';
import { Circle } from '@antv/g';
import G6, { IGraph, stdLib } from '../../src/index';
import { data } from '../datasets/dataset1';
const container = document.createElement('div');
// document.getElementById('__jest-electron-test-results__')?.style.position = 'absolute';
document.querySelector('body')!.appendChild(container);
describe('viewport', () => {
let graph: any;
it('should translate viewport without animation correctly.', (done) => {
graph = new G6.Graph({
container,
width: 500,
height: 500,
type: 'graph',
data,
layout: {
type: 'circular',
center: [250, 250],
radius: 100,
},
});
graph.once('afterlayout', () => {
graph.translate(250, 250);
let [px, py] = graph.canvas.getCamera().getPosition();
expect(px).toBe(0);
expect(py).toBe(0);
graph.translate(-250, -250);
[px, py] = graph.canvas.getCamera().getPosition();
expect(px).toBe(250);
expect(py).toBe(250);
graph.destroy();
done();
});
});
it('should translate viewport with animation correctly.', (done) => {
graph = new G6.Graph({
container,
width: 500,
height: 500,
type: 'graph',
data,
layout: {
type: 'circular',
center: [250, 250],
radius: 200,
},
});
graph.once('afterlayout', async () => {
await graph.translate(249, 249, {
duration: 1000,
});
graph.once('viewportchange', ({ translate }) => {
expect(translate.dx).toBe(-250);
expect(translate.dy).toBe(-250);
const [px, py] = graph.canvas.getCamera().getPosition();
expect(px).toBe(251);
expect(py).toBe(251);
});
await graph.translateTo(
{ x: 500, y: 500 },
{
duration: 2000,
easing: 'ease-in',
},
);
graph.destroy();
done();
});
});
it('should zoom viewport without animation correctly.', (done) => {
graph = new G6.Graph({
container,
width: 500,
height: 500,
type: 'graph',
data,
layout: {
type: 'circular',
center: [250, 250],
radius: 200,
},
});
graph.once('afterlayout', async () => {
graph.once('viewportchange', ({ zoom }) => {
expect(zoom.ratio).toBe(0.5);
expect(graph.canvas.getCamera().getZoom()).toBe(0.5);
});
await graph.zoom(0.5, { x: 250, y: 250 });
expect(graph.getZoom()).toBe(0.5);
graph.once('viewportchange', ({ zoom }) => {
expect(zoom.ratio).toBe(2);
expect(graph.canvas.getCamera().getZoom()).toBe(1);
});
await graph.zoom(2, { x: 250, y: 250 });
const op = graph.canvas.canvas2Viewport({ x: 450, y: 250 });
graph.once('viewportchange', ({ zoom }) => {
expect(zoom.ratio).toBe(1.2);
expect(graph.canvas.getCamera().getZoom()).toBe(1.2);
// Zoom origin should be fixed.
const { x, y } = graph.canvas.canvas2Viewport({ x: 450, y: 250 });
expect(x).toBeCloseTo(op.x);
expect(y).toBeCloseTo(op.y);
});
await graph.zoomTo(1.2, { x: 450, y: 250 });
expect(graph.getZoom()).toBe(1.2);
graph.destroy();
done();
});
});
it('should zoom viewport with animation correctly.', (done) => {
graph = new G6.Graph({
container,
width: 500,
height: 500,
type: 'graph',
data,
layout: {
type: 'circular',
center: [250, 250],
radius: 200,
},
});
graph.once('afterlayout', async () => {
graph.once('viewportchange', ({ zoom }) => {
expect(zoom.ratio).toBe(0.5);
expect(graph.canvas.getCamera().getZoom()).toBe(0.5);
});
await graph.zoom(
0.5,
{ x: 250, y: 250 },
{
duration: 2000,
},
);
graph.once('viewportchange', ({ zoom }) => {
expect(zoom.ratio).toBe(2);
expect(graph.canvas.getCamera().getZoom()).toBe(1);
expect(graph.canvas.getCamera().getPosition()[0]).toBe(250);
expect(graph.canvas.getCamera().getPosition()[1]).toBe(250);
});
await graph.zoom(
2,
{ x: 250, y: 250 },
{
duration: 2000,
},
);
// const op = graph.canvas.canvas2Viewport({ x: 450, y: 250 });
// graph.once('viewportchange', ({ zoom }) => {
// expect(zoom.ratio).toBe(3);
// expect(graph.canvas.getCamera().getZoom()).toBe(3);
// // Zoom origin should be fixed.
// const { x, y } = graph.canvas.canvas2Viewport({ x: 450, y: 250 });
// expect(x).toBeCloseTo(op.x);
// expect(y).toBeCloseTo(op.y);
// });
// await graph.zoomTo(
// 3,
// { x: 450, y: 250 },
// {
// duration: 1000,
// },
// );
// graph.once('viewportchange', ({ zoom }) => {
// expect(zoom.ratio).toBe(1 / 3);
// expect(graph.canvas.getCamera().getZoom()).toBe(1);
// expect(graph.canvas.getCamera().getPosition()[0]).toBe(250);
// expect(graph.canvas.getCamera().getPosition()[1]).toBe(250);
// });
// await graph.zoomTo(
// 1,
// { x: 250, y: 250 },
// {
// duration: 1000,
// },
// );
graph.destroy();
done();
});
});
it('should rotate viewport correctly.', (done) => {
graph = new G6.Graph({
container,
width: 500,
height: 500,
type: 'graph',
data,
layout: {
type: 'circular',
center: [250, 250],
radius: 200,
},
});
graph.once('afterlayout', async () => {
graph.once('viewportchange', ({ rotate }) => {
expect(rotate.angle).toBe(30);
expect(graph.canvas.getCamera().getRoll()).toBe(30);
});
await graph.rotateTo(30, undefined, {
duration: 1000,
});
graph.once('viewportchange', ({ rotate }) => {
expect(rotate.angle).toBe(30);
expect(graph.canvas.getCamera().getRoll()).toBe(60);
});
await graph.rotateTo(60, undefined, {
duration: 1000,
});
graph.once('viewportchange', ({ rotate }) => {
expect(rotate.angle).toBe(-30);
expect(graph.canvas.getCamera().getRoll()).toBe(30);
});
await graph.rotateTo(30, undefined, {
duration: 1000,
});
graph.once('viewportchange', ({ rotate }) => {
expect(rotate.angle).toBe(-29);
expect(graph.canvas.getCamera().getRoll()).toBe(1);
});
// without animation
await graph.rotate(-29);
graph.destroy();
done();
});
});
it('should transform viewport without animation correctly.', (done) => {
graph = new G6.Graph({
container,
width: 500,
height: 500,
type: 'graph',
data,
layout: {
type: 'circular',
center: [250, 250],
radius: 200,
},
});
graph.once('afterlayout', async () => {
const origin = new Circle({
style: {
cx: 450,
cy: 250,
r: 30,
fill: 'green',
opacity: 0.5,
},
});
graph.canvas.appendChild(origin);
// graph.once('viewportchange', ({ zoom }) => {
// expect(zoom.ratio).toBe(0.5);
// expect(graph.canvas.getCamera().getZoom()).toBe(0.5);
// });
// await graph.transform({
// zoom: {
// ratio: 0.5,
// },
// origin: { x: 250, y: 250 },
// });
// graph.once('viewportchange', ({ zoom, rotate }) => {
// expect(zoom.ratio).toBe(2);
// expect(rotate.angle).toBe(30);
// expect(graph.canvas.getCamera().getZoom()).toBe(1);
// expect(graph.canvas.getCamera().getRoll()).toBe(30);
// });
// await graph.transform({
// zoom: {
// ratio: 2,
// },
// rotate: {
// angle: 30,
// },
// origin: { x: 250, y: 250 },
// });
// graph.once('viewportchange', ({ rotate }) => {
// expect(rotate.angle).toBe(-30);
// expect(graph.canvas.getCamera().getZoom()).toBe(1);
// expect(graph.canvas.getCamera().getRoll()).toBe(0);
// });
// await graph.transform({
// rotate: {
// angle: -30,
// },
// origin: { x: 250, y: 250 },
// });
graph.once('viewportchange', ({ translate }) => {
expect(translate.dx).toBe(-250);
expect(translate.dy).toBe(-250);
// expect(graph.canvas.getCamera().getZoom()).toBe(0.5);
// expect(graph.canvas.getCamera().getRoll()).toBe(0);
});
await graph.transform({
translate: {
dx: -250,
dy: -250,
},
});
graph.once('viewportchange', ({ zoom }) => {
expect(zoom.ratio).toBe(0.5);
expect(graph.canvas.getCamera().getZoom()).toBe(0.5);
});
await graph.transform({
zoom: {
ratio: 0.5,
},
origin: { x: 0, y: 0 },
});
await graph.transform({
translate: {
dx: 250,
dy: 250,
},
zoom: {
ratio: 4,
},
});
await graph.transform({
translate: {
dx: -250,
dy: -250,
},
});
// graph.once('viewportchange', ({ rotate }) => {
// // expect(rotate.angle).toBe(0);
// expect(graph.canvas.getCamera().getZoom()).toBe(0.5);
// // expect(graph.canvas.getCamera().getRoll()).toBe(0);
// });
// await graph.transform({
// // rotate: {
// // angle: 0,
// // },
// zoom: {
// ratio: 1,
// },
// origin: { x: 450, y: 250 },
// });
graph.destroy();
origin.destroy();
done();
});
});
it('should stop the current transition of transform correctly.', (done) => {
graph = new G6.Graph({
container,
width: 500,
height: 500,
type: 'graph',
data,
layout: {
type: 'circular',
center: [250, 250],
radius: 200,
},
});
graph.once('afterlayout', async () => {
graph.once('viewportchange', ({ translate }) => {
expect(translate.dx).toBe(-250);
expect(translate.dy).toBe(-250);
});
await graph.transform(
{
translate: {
dx: -250,
dy: -250,
},
},
{
duration: 2000,
},
);
graph.stopTransformTransition();
graph.destroy();
done();
});
});
it('should fitCenter with transition correctly.', (done) => {
graph = new G6.Graph({
container,
width: 500,
height: 500,
type: 'graph',
data,
layout: {
type: 'circular',
center: [250, 250],
radius: 200,
},
});
graph.once('afterlayout', async () => {
await graph.translate(249, 249, {
duration: 1000,
});
graph.once('viewportchange', ({ translate }) => {
expect(translate.dx).toBeCloseTo(-249);
expect(translate.dy).toBeCloseTo(-249);
const [px, py] = graph.canvas.getCamera().getPosition();
expect(px).toBeCloseTo(250);
expect(py).toBeCloseTo(250);
});
await graph.fitCenter({
duration: 2000,
easing: 'ease-in',
});
await graph.zoom(0.5);
await graph.translate(249, 249);
graph.once('viewportchange', ({ translate }) => {
expect(translate.dx).toBeCloseTo(-249);
expect(translate.dy).toBeCloseTo(-249);
const [px, py] = graph.canvas.getCamera().getPosition();
expect(px).toBeCloseTo(250);
expect(py).toBeCloseTo(250);
});
await graph.fitCenter();
graph.destroy();
done();
});
});
it('should focusItem with transition correctly.', (done) => {
graph = new G6.Graph({
container,
width: 500,
height: 500,
type: 'graph',
data,
layout: {
type: 'circular',
center: [250, 250],
radius: 200,
},
});
graph.once('afterlayout', async () => {
const nodesData = graph.getAllNodesData();
expect(nodesData[0].id).toBe('Argentina');
expect(nodesData[0].data.x).toBe(450);
expect(nodesData[0].data.y).toBe(250);
expect(nodesData[4].id).toBe('Colombia');
expect(nodesData[4].data.x).toBeCloseTo(391.421356237309);
expect(nodesData[4].data.y).toBeCloseTo(391.4213562373095);
graph.once('viewportchange', () => {
const [px, py] = graph.canvas.getCamera().getPosition();
expect(px).toBeCloseTo(450);
expect(py).toBeCloseTo(250);
});
await graph.focusItem('Argentina', {
duration: 1000,
easing: 'ease-in',
});
graph.once('viewportchange', () => {
const [px, py] = graph.canvas.getCamera().getPosition();
expect(px).toBeCloseTo(391.421356237309);
expect(py).toBeCloseTo(391.4213562373095);
});
await graph.focusItem('Colombia', {
duration: 1000,
});
await graph.focusItem('Spain', {
duration: 1000,
easing: 'ease-in',
});
graph.once('viewportchange', () => {
const [px, py] = graph.canvas.getCamera().getPosition();
expect(px).toBe(450);
expect(py).toBe(250);
});
await graph.focusItem('Argentina');
graph.once('viewportchange', () => {
const [px, py] = graph.canvas.getCamera().getPosition();
expect(px).toBeCloseTo(250);
expect(py).toBeCloseTo(250);
});
await graph.focusItem(nodesData.map((node) => node.id));
graph.focusItem('Non existed node');
graph.destroy();
done();
});
});
it('should fitView with transition correctly.', (done) => {
graph = new G6.Graph({
container,
width: 500,
height: 500,
type: 'graph',
data,
layout: {
type: 'circular',
center: [250, 250],
radius: 200,
},
});
graph.once('afterlayout', async () => {
const nodesData = graph.getAllNodesData();
expect(nodesData[0].id).toBe('Argentina');
expect(nodesData[0].data.x).toBe(450);
expect(nodesData[0].data.y).toBe(250);
expect(nodesData[4].id).toBe('Colombia');
expect(nodesData[4].data.x).toBeCloseTo(391.421356237309);
expect(nodesData[4].data.y).toBeCloseTo(391.4213562373095);
graph.once('viewportchange', () => {
const [px, py] = graph.canvas.getCamera().getPosition();
expect(px).toBeCloseTo(450);
expect(py).toBeCloseTo(250);
});
await graph.focusItem('Argentina', {
duration: 1000,
easing: 'ease-in',
});
await graph.zoom(0.5, undefined, {
duration: 1000,
easing: 'ease-in',
});
await graph.fitView(
{
padding: [50, 50, 50, 50],
},
{
duration: 1000,
easing: 'ease-in',
},
);
await graph.translate(100, 100, {
duration: 1000,
easing: 'ease-in',
});
await graph.fitView(
{
padding: [150, 100],
rules: {
direction: 'both',
ratioRule: 'min',
},
},
{
duration: 1000,
easing: 'ease-in',
},
);
await graph.fitView(undefined, {
duration: 1000,
easing: 'ease-in',
});
graph.destroy();
done();
});
});
});