使用相机实现视口相关功能 (#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 './interaction';
export * from './item'; export * from './item';
export * from './layout'; export * from './layout';
export * from './viewport';
export * from './theme'; export * from './theme';
export * from './extension'; export * from './extension';

View File

@ -1,18 +1,17 @@
import { AABB, Canvas, DisplayObject, Group } from '@antv/g';
import { GraphChange, ID } from '@antv/graphlib'; 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 { 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 { 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'; import { upsertShape } from '../../util/shape';
/** /**
@ -61,7 +60,7 @@ export class ItemController {
constructor(graph: IGraph<any, any>) { constructor(graph: IGraph<any, any>) {
this.graph = graph; this.graph = graph;
// get mapper for node / edge / combo // 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.nodeMapper = node;
this.edgeMapper = edge; this.edgeMapper = edge;
this.comboMapper = combo; this.comboMapper = combo;
@ -114,7 +113,7 @@ export class ItemController {
* Listener of runtime's render hook. * Listener of runtime's render hook.
* @param param contains inner data stored in graphCore structure * @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 { graphCore, theme = {} } = param;
const { graph } = this; const { graph } = this;
// TODO: 0. clear groups on canvas, and create new groups // TODO: 0. clear groups on canvas, and create new groups
@ -177,11 +176,17 @@ export class ItemController {
// === 3. add nodes === // === 3. add nodes ===
if (groupedChanges.NodeAdded.length) { 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 === // === 4. add edges ===
if (groupedChanges.EdgeAdded.length) { 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 === // === 5. update nodes's data ===
@ -209,7 +214,12 @@ export class ItemController {
// update the theme if the dataType value is changed // update the theme if the dataType value is changed
let themeStyles; let themeStyles;
if (previous[nodeDataTypeField] !== current[nodeDataTypeField]) { 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 item = itemMap[id];
const innerModel = graphCore.getNode(id); const innerModel = graphCore.getNode(id);
@ -247,7 +257,12 @@ export class ItemController {
// update the theme if the dataType value is changed // update the theme if the dataType value is changed
let themeStyles; let themeStyles;
if (previous[edgeDataTypeField] !== current[edgeDataTypeField]) { 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 item = itemMap[id];
const innerModel = graphCore.getEdge(id); const innerModel = graphCore.getEdge(id);
@ -283,9 +298,9 @@ export class ItemController {
* value: state value * 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; const { ids, states, value } = param;
ids.forEach(id => { ids.forEach((id) => {
const item = this.itemMap[id]; const item = this.itemMap[id];
if (!item) { if (!item) {
console.warn(`Fail to set state for item ${id}, which is not exist.`); console.warn(`Fail to set state for item ${id}, which is not exist.`);
@ -295,20 +310,20 @@ export class ItemController {
// clear all the states // clear all the states
item.clearStates(states); item.clearStates(states);
} else { } else {
states.forEach(state => item.setState(state, value)); states.forEach((state) => item.setState(state, value));
} }
}); });
} }
private onTransientUpdate(param: { private onTransientUpdate(param: {
type: ITEM_TYPE | SHAPE_TYPE, type: ITEM_TYPE | SHAPE_TYPE;
id: ID, id: ID;
config: { config: {
style: ShapeStyle, style: ShapeStyle;
action: 'remove' | 'add' | 'update' | undefined, action: 'remove' | 'add' | 'update' | undefined;
[shapeConfig: string]: unknown, [shapeConfig: string]: unknown;
}, };
canvas: Canvas canvas: Canvas;
}) { }) {
const { transientMap } = this; const { transientMap } = this;
const { type, id, config = {}, canvas } = param; const { type, id, config = {}, canvas } = param;
@ -345,7 +360,7 @@ export class ItemController {
let dataType; let dataType;
if (dataTypeField) dataType = node.data[dataTypeField] as string; if (dataTypeField) dataType = node.data[dataTypeField] as string;
const themeStyle = getThemeStyles(nodeDataTypeSet, dataTypeField, dataType, nodeTheme); const themeStyle = getThemeStyles(nodeDataTypeSet, dataTypeField, dataType, nodeTheme);
this.itemMap[node.id] = new Node({ this.itemMap[node.id] = new Node({
model: node, model: node,
renderExtensions: nodeExtensions, renderExtensions: nodeExtensions,
@ -405,7 +420,7 @@ export class ItemController {
*/ */
public findIdByState(itemType: ITEM_TYPE, state: string, value: string | boolean = true) { public findIdByState(itemType: ITEM_TYPE, state: string, value: string | boolean = true) {
const ids = []; const ids = [];
Object.values(this.itemMap).forEach(item => { Object.values(this.itemMap).forEach((item) => {
if (item.getType() !== itemType) return; if (item.getType() !== itemType) return;
if (item.hasState(state) === value) ids.push(item.getID()); if (item.hasState(state) === value) ids.push(item.getID());
}); });
@ -427,6 +442,10 @@ export class ItemController {
return item.hasState(state); return item.hasState(state);
} }
public getItemById(id: ID) {
return this.itemMap[id];
}
public getItemBBox(id: ID, isKeyShape: boolean = false): AABB | false { public getItemBBox(id: ID, isKeyShape: boolean = false): AABB | false {
const item = this.itemMap[id]; const item = this.itemMap[id];
if (!item) { 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; const { styles: themeStyles } = itemTheme;
if (!dataTypeField) { if (!dataTypeField) {
// dataType field is not assigned // dataType field is not assigned
@ -462,4 +486,4 @@ const getThemeStyles = (dataTypeSet: Set<string>, dataTypeField: string, dataTyp
themeStyle = themeStyles[dataType] || themeStyles.others; themeStyle = themeStyles[dataType] || themeStyles.others;
} }
return themeStyle; 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 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 { GraphChange, ID } from '@antv/graphlib';
import { isArray, isNil, isNumber, isObject, isString } from '@antv/util'; import { isArray, isNil, isNumber, isObject, isString } from '@antv/util';
import { import {
@ -10,13 +10,13 @@ import {
NodeUserModel, NodeUserModel,
Specification, Specification,
} from '../types'; } from '../types';
import { AnimateCfg } from '../types/animate'; import { CameraAnimationOptions } from '../types/animate';
import { BehaviorObjectOptionsOf, BehaviorOptionsOf, BehaviorRegistry } from '../types/behavior'; import { BehaviorObjectOptionsOf, BehaviorOptionsOf, BehaviorRegistry } from '../types/behavior';
import { ComboModel } from '../types/combo'; import { ComboModel } from '../types/combo';
import { Padding, Point } from '../types/common'; import { Padding } from '../types/common';
import { GraphCore } from '../types/data'; import { GraphCore } from '../types/data';
import { EdgeModel, EdgeModelData } from '../types/edge'; 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 { ITEM_TYPE, ShapeStyle, SHAPE_TYPE } from '../types/item';
import { import {
ImmediatelyInvokedLayoutOptions, ImmediatelyInvokedLayoutOptions,
@ -25,8 +25,9 @@ import {
} from '../types/layout'; } from '../types/layout';
import { NodeModel, NodeModelData } from '../types/node'; import { NodeModel, NodeModelData } from '../types/node';
import { ThemeRegistry, ThemeSpecification } from '../types/theme'; import { ThemeRegistry, ThemeSpecification } from '../types/theme';
import { FitViewRules, GraphAlignment } from '../types/view'; import { FitViewRules, GraphTransformOptions } from '../types/view';
import { createCanvas } from '../util/canvas'; import { createCanvas } from '../util/canvas';
import { formatPadding } from '../util/shape';
import { import {
DataController, DataController,
ExtensionController, ExtensionController,
@ -34,6 +35,7 @@ import {
ItemController, ItemController,
LayoutController, LayoutController,
ThemeController, ThemeController,
ViewportController,
} from './controller'; } from './controller';
import Hook from './hooks'; import Hook from './hooks';
@ -42,7 +44,10 @@ import Hook from './hooks';
*/ */
runtime.enableCSSParsing = false; 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; public hooks: Hooks;
// for nodes and edges, which will be separate into groups // for nodes and edges, which will be separate into groups
public canvas: Canvas; public canvas: Canvas;
@ -58,6 +63,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
private dataController: DataController; private dataController: DataController;
private interactionController: InteractionController; private interactionController: InteractionController;
private layoutController: LayoutController; private layoutController: LayoutController;
private viewportController: ViewportController;
private itemController: ItemController; private itemController: ItemController;
private extensionController: ExtensionController; private extensionController: ExtensionController;
private themeController: ThemeController; private themeController: ThemeController;
@ -65,9 +71,9 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
private defaultSpecification = { private defaultSpecification = {
theme: { theme: {
type: 'spec', type: 'spec',
base: 'light' base: 'light',
} },
} };
constructor(spec: Specification<B, T>) { constructor(spec: Specification<B, T>) {
super(); super();
@ -82,8 +88,8 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
canvases: { canvases: {
background: this.backgroundCanvas, background: this.backgroundCanvas,
main: this.canvas, main: this.canvas,
transient: this.transientCanvas transient: this.transientCanvas,
} },
}); });
const { data } = spec; const { data } = spec;
@ -102,6 +108,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
this.layoutController = new LayoutController(this); this.layoutController = new LayoutController(this);
this.themeController = new ThemeController(this); this.themeController = new ThemeController(this);
this.itemController = new ItemController(this); this.itemController = new ItemController(this);
this.viewportController = new ViewportController(this);
this.extensionController = new ExtensionController(this); this.extensionController = new ExtensionController(this);
} }
@ -132,10 +139,10 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
this.hooks = { this.hooks = {
init: new Hook<{ init: new Hook<{
canvases: { canvases: {
background: Canvas, background: Canvas;
main: Canvas, main: Canvas;
transient: Canvas transient: Canvas;
} };
}>({ name: 'init' }), }>({ name: 'init' }),
datachange: new Hook<{ data: GraphData; type: 'replace' }>({ name: 'datachange' }), datachange: new Hook<{ data: GraphData; type: 'replace' }>({ name: 'datachange' }),
itemchange: new Hook<{ itemchange: new Hook<{
@ -144,16 +151,24 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
graphCore: GraphCore; graphCore: GraphCore;
theme: ThemeSpecification; theme: ThemeSpecification;
}>({ name: 'itemchange' }), }>({ 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' }), layout: new Hook<{ graphCore: GraphCore }>({ name: 'layout' }),
viewportchange: new Hook<ViewportChangeHookParams>({ name: 'viewport' }),
modechange: new Hook<{ mode: string }>({ name: 'modechange' }), modechange: new Hook<{ mode: string }>({ name: 'modechange' }),
behaviorchange: new Hook<{ behaviorchange: new Hook<{
action: 'update' | 'add' | 'remove'; action: 'update' | 'add' | 'remove';
modes: string[]; modes: string[];
behaviors: BehaviorOptionsOf<{}>[]; behaviors: BehaviorOptionsOf<{}>[];
}>({ name: 'behaviorchange' }), }>({ name: 'behaviorchange' }),
itemstatechange: new Hook<{ ids: ID[], state: string, value: boolean }>({ name: 'itemstatechange' }), itemstatechange: new Hook<{ ids: ID[]; state: string; value: boolean }>({
transientupdate: new Hook<{ type: ITEM_TYPE | SHAPE_TYPE, id: ID, config: { style: ShapeStyle, action: 'remove' | 'add' | 'update' | undefined }, canvas: Canvas }>({ name: 'transientupdate'}), // TODO 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 () => { const emitRender = async () => {
this.hooks.render.emit({ this.hooks.render.emit({
graphCore: this.dataController.graphCore, graphCore: this.dataController.graphCore,
theme: this.themeController.specification theme: this.themeController.specification,
}); });
this.emit('afterrender'); 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.datachange.emit({ data, type });
this.hooks.render.emit({ this.hooks.render.emit({
graphCore: this.dataController.graphCore, graphCore: this.dataController.graphCore,
theme: this.themeController.specification theme: this.themeController.specification,
}); });
this.emit('afterrender'); this.emit('afterrender');
@ -226,84 +241,223 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
// TODO // TODO
} }
/** public getViewportCenter(): PointLike {
* Move the graph with a relative vector. const { width, height } = this.canvas.getConfig();
* @param dx x of the relative vector return { x: width! / 2, y: height! / 2 };
* @param dy y of the relative vector }
* @param animateCfg animation configurations public async transform(
* @returns options: GraphTransformOptions,
* @group View effectTiming?: CameraAnimationOptions,
*/ ): Promise<void> {
public move(dx: number, dy: number, animateCfg?: AnimateCfg) { await this.hooks.viewportchange.emitLinearAsync({
// TODO transform: options,
effectTiming,
});
this.emit('viewportchange', options);
} }
/** /**
* Move the graph and align to a point. * Stop the current transition of transform immediately.
* @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
*/ */
public moveTo(x: number, y: number, alignment: GraphAlignment, animateCfg?: AnimateCfg) { public stopTransformTransition() {
// TODO 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. * Zoom the graph with a relative ratio.
* @param ratio relative ratio to zoom * @param ratio relative ratio to zoom
* @param center zoom center * @param origin origin under viewport coordinates.
* @param animateCfg animation configurations * @param effectTiming animation configurations
* @returns
* @group View
*/ */
public zoom(ratio: number, center?: Point, animateCfg?: AnimateCfg) { public async zoom(ratio: number, origin?: PointLike, effectTiming?: CameraAnimationOptions) {
// TODO await this.transform(
{
zoom: {
ratio,
},
origin,
},
effectTiming,
);
} }
/** /**
* Zoom the graph to a specified ratio. * Zoom the graph to a specified ratio.
* @param toRatio specified ratio * @param zoom specified ratio
* @param center zoom center * @param origin zoom center
* @param animateCfg animation configurations * @param effectTiming animation configurations
* @returns
* @group View
*/ */
public zoomTo(toRatio: number, center?: Point, animateCfg?: AnimateCfg) { public async zoomTo(zoom: number, origin?: PointLike, effectTiming?: CameraAnimationOptions) {
// TODO 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. * Fit the graph content to the view.
* @param padding padding while fitting * @param options.padding padding while fitting
* @param rules rules for fitting * @param options.rules rules for fitting
* @param animateCfg animation configurations * @param effectTiming animation configurations
* @returns
* @group View
*/ */
public fitView(padding?: Padding, rules?: FitViewRules, animateCfg?: AnimateCfg) { public async fitView(
// TODO 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. * Fit the graph center to the view center.
* @param animateCfg animation configurations * @param effectTiming animation configurations
* @returns
* @group View
*/ */
public fitCenter(animateCfg?: AnimateCfg) { public async fitCenter(effectTiming?: CameraAnimationOptions) {
// TODO // 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. * Move the graph to make the item align the view center.
* @param item node/edge/combo item or its id * @param item node/edge/combo item or its id
* @param animateCfg animation configurations * @param effectTiming animation configurations
* @returns
* @group View
*/ */
public focusItem(ids: ID | ID[], animateCfg?: AnimateCfg) { public async focusItem(id: ID | ID[], effectTiming?: CameraAnimationOptions) {
// TODO 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 ===== // ===== 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[] { public getRelatedEdgesData(nodeId: ID, direction: 'in' | 'out' | 'both' = 'both'): EdgeModel[] {
return this.dataController.findRelatedEdgeIds(nodeId, direction); return this.dataController.findRelatedEdgeIds(nodeId, direction);
} }
/** /**
* Get one-hop node ids from a start node. * Get one-hop node ids from a start node.
* @param nodeId id of the start node * @param nodeId id of the start node
* @returns one-hop node ids * @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[] { public getNeighborNodesData(nodeId: ID, direction: 'in' | 'out' | 'both' = 'both'): NodeModel[] {
return this.dataController.findNeighborNodeIds(nodeId, direction); return this.dataController.findNeighborNodeIds(nodeId, direction);
} }
/** /**
* Find items which has the state. * Find items which has the state.
* @param itemType item type * @param itemType item type
@ -430,7 +584,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
type: itemType, type: itemType,
changes: graphCore.reduceChanges(event.changes), changes: graphCore.reduceChanges(event.changes),
graphCore, graphCore,
theme: specification theme: specification,
}); });
}); });
@ -466,7 +620,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
type: itemType, type: itemType,
changes: event.changes, changes: event.changes,
graphCore, graphCore,
theme: specification theme: specification,
}); });
}); });
this.hooks.datachange.emit({ this.hooks.datachange.emit({
@ -505,7 +659,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
type: itemType, type: itemType,
changes: event.changes, changes: event.changes,
graphCore, 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 * @returns rendering bounding box. returns false if the item is not exist
* @group Item * @group Item
*/ */
public getRenderBBox(id: ID | undefined): AABB | false{ public getRenderBBox(id: ID | undefined): AABB | false {
if (!id) return this.canvas.getRoot().getRenderBounds(); if (!id) return this.canvas.getRoot().getRenderBounds();
return this.itemController.getItemBBox(id); return this.itemController.getItemBBox(id);
} }
@ -769,7 +923,11 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
* @returns upserted shape or group * @returns upserted shape or group
* @group Interaction * @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 }); this.hooks.transientupdate.emit({ type, id, config, canvas: this.transientCanvas });
return this.itemController.getTransient(String(id)); return this.itemController.getTransient(String(id));
} }

View File

@ -1,3 +1,5 @@
import { IAnimationEffectTiming } from '@antv/g';
export interface AnimateCfg { export interface AnimateCfg {
/** /**
* Whether enable animation. * Whether enable animation.
@ -39,7 +41,7 @@ export interface AnimateCfg {
* @type {function} * @type {function}
*/ */
resumeCallback?: () => void; resumeCallback?: () => void;
}; }
export type AnimateWhen = 'show' | 'exit' | 'update' | 'last'; export type AnimateWhen = 'show' | 'exit' | 'update' | 'last';
@ -47,4 +49,7 @@ export interface AnimateAttr {
when: AnimateWhen; when: AnimateWhen;
type: string; type: string;
[param: string]: unknown; [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 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 { ID } from '@antv/graphlib';
import { Hooks } from '../types/hook'; import { Hooks } from '../types/hook';
import { AnimateCfg } from './animate'; import { CameraAnimationOptions } from './animate';
import { BehaviorObjectOptionsOf, BehaviorOptionsOf, BehaviorRegistry } from './behavior'; import { BehaviorObjectOptionsOf, BehaviorOptionsOf, BehaviorRegistry } from './behavior';
import { ComboModel, ComboUserModel } from './combo'; import { ComboModel, ComboUserModel } from './combo';
import { Padding, Point } from './common'; import { Padding, Point } from './common';
import { DataChangeType, GraphData } from './data'; import { GraphData } from './data';
import { EdgeModel, EdgeUserModel } from './edge'; import { EdgeModel, EdgeUserModel } from './edge';
import { ITEM_TYPE, SHAPE_TYPE } from './item'; import { ITEM_TYPE, SHAPE_TYPE } from './item';
import { LayoutOptions } from './layout'; import { LayoutOptions } from './layout';
import { NodeModel, NodeUserModel } from './node'; import { NodeModel, NodeUserModel } from './node';
import { Specification } from './spec'; import { Specification } from './spec';
import { ThemeRegistry } from './theme'; 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; hooks: Hooks;
canvas: Canvas; canvas: Canvas;
destroyed: boolean; destroyed: boolean;
@ -182,63 +185,98 @@ export interface IGraph<B extends BehaviorRegistry = BehaviorRegistry, T extends
* Move the graph with a relative vector. * Move the graph with a relative vector.
* @param dx x of the relative vector * @param dx x of the relative vector
* @param dy y of the relative vector * @param dy y of the relative vector
* @param animateCfg animation configurations * @param effectTiming animation configurations
* @returns
* @group View
*/ */
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. * Move the graph and align to a point.
* @param x position on the canvas to align * @param point position on the canvas to align
* @param y position on the canvas to align * @param effectTiming animation configurations
* @param alignment alignment of the graph content
* @param animateCfg animation configurations
* @returns
* @group View
*/ */
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. * Zoom the graph with a relative ratio.
* @param ratio relative ratio to zoom * @param ratio relative ratio to zoom
* @param center zoom center * @param center zoom center
* @param animateCfg animation configurations * @param effectTiming animation configurations
* @returns
* @group View
*/ */
zoom: (ratio: number, center?: Point, animateCfg?: AnimateCfg) => void; zoom: (ratio: number, center?: Point, effectTiming?: CameraAnimationOptions) => Promise<void>;
/** /**
* Zoom the graph to a specified ratio. * Zoom the graph to a specified ratio.
* @param toRatio specified ratio * @param toRatio specified ratio
* @param center zoom center * @param center zoom center
* @param animateCfg animation configurations * @param effectTiming animation configurations
* @returns
* @group View
*/ */
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. * Fit the graph content to the view.
* @param padding padding while fitting * @param options.padding padding while fitting
* @param rules rules for fitting * @param options.rules rules for fitting
* @param animateCfg animation configurations * @param effectTiming animation configurations
* @returns * @returns
* @group View * @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. * Fit the graph center to the view center.
* @param animateCfg animation configurations * @param effectTiming animation configurations
* @returns * @returns
* @group View * @group View
*/ */
fitCenter: (animateCfg?: AnimateCfg) => void; fitCenter: (effectTiming?: CameraAnimationOptions) => Promise<void>;
/** /**
* Move the graph to make the item align the view center. * Move the graph to make the item align the view center.
* @param item node/edge/combo item or its id * @param item node/edge/combo item or its id
* @param animateCfg animation configurations * @param effectTiming animation configurations
* @returns
* @group View
*/ */
focusItem: (ids: ID | ID[], animateCfg?: AnimateCfg) => void; focusItem: (id: ID | ID[], effectTiming?: CameraAnimationOptions) => Promise<void>;
// ===== item operations ===== // ===== item operations =====
/** /**

View File

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

View File

@ -4,4 +4,27 @@ export interface FitViewRules {
ratioRule?: 'max' | 'min'; // Ratio rule to fit. 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. * 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 * @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[]) => { export const mergeStyles = (styleMaps: ItemShapeStyles[]) => {
let currentResult = styleMaps[0]; let currentResult = styleMaps[0];
@ -148,29 +148,29 @@ export const mergeStyles = (styleMaps: ItemShapeStyles[]) => {
if (i > 0) currentResult = merge2Styles(currentResult, styleMap); if (i > 0) currentResult = merge2Styles(currentResult, styleMap);
}); });
return currentResult; return currentResult;
} };
/** /**
* Merge two shape style map including undefined value in incoming map. * Merge two shape style map including undefined value in incoming map.
* @param styleMap1 shapes' styles map as current map * @param styleMap1 shapes' styles map as current map
* @param styleMap2 shapes' styles map as incoming map * @param styleMap2 shapes' styles map as incoming map
* @returns * @returns
*/ */
const merge2Styles = (styleMap1: ItemShapeStyles, styleMap2: ItemShapeStyles) => { const merge2Styles = (styleMap1: ItemShapeStyles, styleMap2: ItemShapeStyles) => {
if (!styleMap1) return clone(styleMap2); if (!styleMap1) return clone(styleMap2);
else if (!styleMap2) return clone(styleMap1); else if (!styleMap2) return clone(styleMap1);
const mergedStyle = clone(styleMap1); const mergedStyle = clone(styleMap1);
Object.keys(styleMap2).forEach(shapeId => { Object.keys(styleMap2).forEach((shapeId) => {
const style = styleMap2[shapeId]; const style = styleMap2[shapeId];
mergedStyle[shapeId] = mergedStyle[shapeId] || {}; mergedStyle[shapeId] = mergedStyle[shapeId] || {};
if (!style) return; if (!style) return;
Object.keys(style).forEach(styleName => { Object.keys(style).forEach((styleName) => {
const value = style[styleName]; const value = style[styleName];
mergedStyle[shapeId][styleName] = value; mergedStyle[shapeId][styleName] = value;
}); });
}); });
return mergedStyle; return mergedStyle;
} };
/** /**
* Whether two polygons are intersected. * Whether two polygons are intersected.
@ -179,10 +179,10 @@ const merge2Styles = (styleMap1: ItemShapeStyles, styleMap2: ItemShapeStyles) =>
*/ */
export const isPolygonsIntersect = (points1: number[][], points2: number[][]): boolean => { export const isPolygonsIntersect = (points1: number[][], points2: number[][]): boolean => {
const getBBox = (points): Partial<AABB> => { const getBBox = (points): Partial<AABB> => {
const xArr = points.map(p => p[0]); const xArr = points.map((p) => p[0]);
const yArr = points.map(p => p[1]); const yArr = points.map((p) => p[1]);
return { 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], 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; let isIn = false;
// 判定点是否在多边形内部,一旦有一个点在另一个多边形内,则返回 // 判定点是否在多边形内部,一旦有一个点在另一个多边形内,则返回
points2.forEach(point => { points2.forEach((point) => {
if (isPointInPolygon(points1, point[0], point[1])) { if (isPointInPolygon(points1, point[0], point[1])) {
isIn = true; isIn = true;
return false; return false;
@ -244,7 +244,7 @@ export const isPolygonsIntersect = (points1: number[][], points2: number[][]): b
if (isIn) { if (isIn) {
return true; return true;
} }
points1.forEach(point => { points1.forEach((point) => {
if (isPointInPolygon(points2, point[0], point[1])) { if (isPointInPolygon(points2, point[0], point[1])) {
isIn = true; isIn = true;
return false; return false;
@ -257,7 +257,7 @@ export const isPolygonsIntersect = (points1: number[][], points2: number[][]): b
const lines1 = parseToLines(points1); const lines1 = parseToLines(points1);
const lines2 = parseToLines(points2); const lines2 = parseToLines(points2);
let isIntersect = false; let isIntersect = false;
lines2.forEach(line => { lines2.forEach((line) => {
if (lineIntersectPolygon(lines1, line)) { if (lineIntersectPolygon(lines1, line)) {
isIntersect = true; isIntersect = true;
return false; 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 points
* @param x * @param x
* @param y * @param y
@ -320,7 +320,7 @@ export const isPointInPolygon = (points: number[][], x: number, y: number) => {
* @param p1 begin of segment line * @param p1 begin of segment line
* @param p2 end of segment line * @param p2 end of segment line
* @param q the point to be judged * @param q the point to be judged
* @returns * @returns
*/ */
const onSegment = (p1, p2, q) => { const onSegment = (p1, p2, q) => {
if ( if (
@ -333,11 +333,11 @@ const onSegment = (p1, p2, q) => {
return true; return true;
} }
return false; return false;
} };
const lineIntersectPolygon = (lines, line) => { const lineIntersectPolygon = (lines, line) => {
let isIntersect = false; let isIntersect = false;
lines.forEach(l => { lines.forEach((l) => {
if (getLineIntersect(l.from, l.to, line.from, line.to)) { if (getLineIntersect(l.from, l.to, line.from, line.to)) {
isIntersect = true; isIntersect = true;
return false; 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();
});
});
});