feat: a demo for v5 and fix several bugs, details are in commits (#4553)

* feat: demo

* chore: refine demo

* chore: refine demo

* perf: demo with themes

* perf: demo with webgl renderer

* feat: add 3d layout demo

* fix: state style affect size of node incorrectly

* feat: support text in 3D scene

* fix: conflict between behaviors; fix: animate problems; fix: zoomStrategy for individual nodes

* chore: rename zoomStrategy to lodStrategy

* chore: refine demo

* chore: demo refine

* chore: type templates problems

* chore: lint

* chore: refine

* feat: constrain the range of zoom-canvas-3d

* chore: refine

* chore: remove unecessary notes

---------

Co-authored-by: yuqi.pyq <yuqi.pyq@antgroup.com>
This commit is contained in:
Yanyan Wang 2023-05-31 18:25:02 +08:00 committed by GitHub
parent 2b44df189d
commit 1cd6c9d2c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
66 changed files with 78192 additions and 839 deletions

View File

@ -14,6 +14,7 @@
"@commitlint/config-conventional": "^17.4.4",
"husky": "^8.0.3",
"react-scripts": "3.1.2",
"vite": "^4.2.1"
"vite": "^4.2.1",
"stats.js": "^0.17.0"
}
}

View File

@ -249,8 +249,8 @@ Update a behavior on a mode.
#### Parameters
| Name | Type | Description |
| :--------- | :------------------------------ | :---------------------------------------------------------------- |
| `behavior` | `BehaviorObjectOptionsOf`<`B`\> | behavior configs, whose name indicates the behavior to be updated |
| :--------- | :------------------------ | :---------------------------------------------------------------- |
| `behavior` | `BehaviorOptionsOf`<`B`\> | behavior configs, whose name indicates the behavior to be updated |
| `mode?` | `string` | mode name |
#### Returns

View File

@ -1,6 +1,6 @@
{
"name": "@antv/g6",
"version": "5.0.0-beta.1",
"version": "5.0.0-alpha.4",
"description": "A Graph Visualization Framework in JavaScript",
"keywords": [
"antv",
@ -43,7 +43,7 @@
"clean": "rimraf es lib",
"coverage": "jest --coverage",
"doc": "rimraf apis && typedoc",
"lint": "eslint ./src ./tests --quiet && prettier ./src ./tests --check",
"lint": "eslint ./src --quiet && prettier ./src --check",
"lint-staged:js": "eslint --ext .js,.jsx,.ts,.tsx",
"fix": "eslint ./src ./tests --fix && prettier ./src ./tests --write ",
"test": "jest",
@ -60,6 +60,7 @@
},
"dependencies": {
"@ant-design/colors": "^7.0.0",
"@antv/algorithm": "^0.1.25",
"@antv/dom-util": "^2.0.4",
"@antv/g": "^5.15.7",
"@antv/g-canvas": "^1.9.28",
@ -67,11 +68,12 @@
"@antv/g-plugin-control": "^1.7.43",
"@antv/g-svg": "^1.8.36",
"@antv/g-webgl": "^1.7.44",
"@antv/g6": "^4.8.13",
"@antv/graphlib": "^2.0.0",
"@antv/gui": "^0.5.0-alpha.16",
"@antv/layout": "^1.2.0",
"@antv/layout-gpu": "^1.0.0",
"@antv/layout-wasm": "^1.3.0",
"@antv/layout-wasm": "1.3.1",
"@antv/matrix-util": "^3.0.4",
"@antv/util": "~2.0.5",
"color": "^4.2.3",
@ -103,6 +105,7 @@
"rollup-plugin-typescript": "^1.0.1",
"rollup-plugin-uglify": "^6.0.4",
"rollup-plugin-visualizer": "^5.6.0",
"stats.js": "^0.17.0",
"ts-jest": "^24.1.0",
"typedoc": "^0.23.24",
"typescript": "^4.6.3",

View File

@ -12,7 +12,7 @@ module.exports = [
input: 'src/index.ts',
output: {
file: 'dist/g6.min.js',
name: 'G6',
name: 'G6V5',
format: 'umd',
sourcemap: false,
},

View File

@ -2,7 +2,7 @@ import { Group } from '@antv/g';
import { clone, throttle } from '@antv/util';
import { EdgeDisplayModel, EdgeModel, NodeModelData } from '../types';
import { EdgeModelData } from '../types/edge';
import { DisplayMapper, State, ZoomStrategyObj } from '../types/item';
import { DisplayMapper, State, lodStrategyObj } from '../types/item';
import { updateShapes } from '../util/shape';
import Item from './item';
import Node from './node';
@ -22,7 +22,7 @@ interface IProps {
zoom?: number;
theme: {
styles: EdgeStyleSet;
zoomStrategy: ZoomStrategyObj;
lodStrategy: lodStrategyObj;
};
onframe?: Function;
}
@ -88,10 +88,11 @@ export default class Edge extends Item {
if (firstRendering) {
// update the transform
this.renderExt.onZoom(this.shapeMap, this.zoom);
}
} else {
// terminate previous animations
this.stopAnimations();
}
const timing = firstRendering ? 'buildIn' : 'update';
// handle shape's animate
if (!disableAnimate && usingAnimates[timing]?.length) {
@ -154,7 +155,7 @@ export default class Edge extends Item {
}
const clonedModel = clone(this.model);
clonedModel.data.disableAnimate = disableAnimate;
return new Edge({
const clonedEdge = new Edge({
model: clonedModel,
renderExtensions: this.renderExtensions,
sourceItem,
@ -164,9 +165,14 @@ export default class Edge extends Item {
stateMapper: this.stateMapper,
zoom: this.zoom,
theme: {
styles: clone(this.themeStyles),
zoomStrategy: this.zoomStrategy,
styles: this.themeStyles,
lodStrategy: this.lodStrategy,
},
});
Object.keys(this.shapeMap).forEach((shapeId) => {
if (!this.shapeMap[shapeId].isVisible())
clonedEdge.shapeMap[shapeId].hide();
});
return clonedEdge;
}
}

View File

@ -11,7 +11,7 @@ import {
ItemShapeStyles,
ITEM_TYPE,
State,
ZoomStrategyObj,
lodStrategyObj,
} from '../types/item';
import { NodeShapeMap } from '../types/node';
import { EdgeStyleSet, NodeStyleSet } from '../types/theme';
@ -23,8 +23,10 @@ import {
getShapeAnimateBeginStyles,
animateShapes,
GROUP_ANIMATE_STYLES,
stopAnimate,
} from '../util/animate';
import { AnimateTiming, IAnimates } from '../types/animate';
import { formatLodStrategy } from '../util/zoom';
export default abstract class Item implements IItem {
public destroyed = false;
@ -58,6 +60,8 @@ export default abstract class Item implements IItem {
public afterDrawShapeMap = {};
/** Set to different value in implements. */
public type: ITEM_TYPE;
/** The flag of transient item. */
public transient: Boolean = false;
public renderExtensions: any; // TODO
/** Cache the animation instances to stop at next lifecycle. */
public animations: IAnimation[];
@ -66,8 +70,8 @@ export default abstract class Item implements IItem {
default?: ItemShapeStyles;
[stateName: string]: ItemShapeStyles;
};
/** The zoom strategy to show and hide shapes according to their showLevel. */
public zoomStrategy: ZoomStrategyObj;
/** The zoom strategy to show and hide shapes according to their lod. */
public lodStrategy: lodStrategyObj;
/** Last zoom ratio. */
public zoom: number;
/** Cache the chaging states which are not consomed by draw */
@ -108,14 +112,20 @@ export default abstract class Item implements IItem {
this.stateMapper = stateMapper;
this.displayModel = this.getDisplayModelAndChanges(model).model;
this.renderExtensions = renderExtensions;
const { type = this.type === 'node' ? 'circle-node' : 'line-edge' } =
this.displayModel.data;
const {
type = this.type === 'node' ? 'circle-node' : 'line-edge',
lodStrategy: modelLodStrategy,
} = this.displayModel.data;
const RenderExtension = renderExtensions.find((ext) => ext.type === type);
this.themeStyles = theme.styles;
const lodStrategy = modelLodStrategy
? formatLodStrategy(modelLodStrategy)
: theme.lodStrategy;
this.renderExt = new RenderExtension({
themeStyles: this.themeStyles.default,
zoomStrategy: theme.zoomStrategy,
lodStrategy,
device: this.device,
zoom: this.zoom,
});
}
@ -163,7 +173,7 @@ export default abstract class Item implements IItem {
isReplace?: boolean,
itemTheme?: {
styles: NodeStyleSet | EdgeStyleSet;
zoomStrategy: ZoomStrategyObj;
lodStrategy: lodStrategyObj;
},
onlyMove?: boolean,
onfinish?: Function,
@ -172,7 +182,6 @@ export default abstract class Item implements IItem {
this.model = model;
if (itemTheme) {
this.themeStyles = itemTheme.styles;
this.zoomStrategy = itemTheme.zoomStrategy;
}
// 2. map new merged model to displayModel, keep prevModel and newModel for 3.
const { model: displayModel, typeChange } = this.getDisplayModelAndChanges(
@ -182,6 +191,10 @@ export default abstract class Item implements IItem {
);
this.displayModel = displayModel;
this.lodStrategy = displayModel.data.lodStrategy
? formatLodStrategy(displayModel.data.lodStrategy)
: itemTheme?.lodStrategy || this.lodStrategy;
if (onlyMove) {
this.updatePosition(displayModel, diffData, onfinish);
return;
@ -197,12 +210,13 @@ export default abstract class Item implements IItem {
);
this.renderExt = new RenderExtension({
themeStyles: this.themeStyles.default,
zoomStrategy: this.zoomStrategy,
lodStrategy: this.lodStrategy,
device: this.device,
zoom: this.zoom,
});
} else {
this.renderExt.themeStyles = this.themeStyles.default;
this.renderExt.zoomStrategy = this.zoomStrategy;
this.renderExt.lodStrategy = this.lodStrategy;
}
// 3. call element update fn from useLib
if (this.states?.length) {
@ -630,10 +644,7 @@ export default abstract class Item implements IItem {
// displayModel
{
...this.displayModel,
data: {
...displayModelData,
...styles,
},
data: mergeStyles([displayModelData, styles]),
} as ItemDisplayModel,
// diffData
undefined,
@ -675,13 +686,7 @@ export default abstract class Item implements IItem {
* Stop all the animations on the item.
*/
public stopAnimations() {
this.animations?.forEach((animation) => {
const timing = animation.effect.getTiming();
if (animation.playState !== 'running') return;
animation.currentTime =
Number(timing.duration) + Number(timing.delay || 0);
animation.cancel();
});
this.animations?.forEach(stopAnimate);
this.animations = [];
}
@ -719,7 +724,7 @@ export default abstract class Item implements IItem {
// 1. stop animations, run buildOut animations
this.stopAnimations();
const { animates } = this.displayModel.data;
if (animates.buildOut?.length) {
if (animates?.buildOut?.length && !this.transient) {
this.animations = this.runWithAnimates(
animates,
'buildOut',

View File

@ -2,7 +2,7 @@ import { Group } from '@antv/g';
import { clone } from '@antv/util';
import { Point } from '../types/common';
import { NodeModel } from '../types';
import { DisplayMapper, State, ZoomStrategyObj } from '../types/item';
import { DisplayMapper, State, lodStrategyObj } from '../types/item';
import { NodeDisplayModel, NodeModelData } from '../types/node';
import { NodeStyleSet } from '../types/theme';
import { updateShapes } from '../util/shape';
@ -26,7 +26,7 @@ interface IProps {
zoom?: number;
theme: {
styles: NodeStyleSet;
zoomStrategy: ZoomStrategyObj;
lodStrategy: lodStrategyObj;
};
device?: any; // for 3d shapes
onframe?: Function;
@ -74,6 +74,8 @@ export default class Node extends Item {
// first rendering, move the group
group.setLocalPosition(x, y, z);
} else {
// terminate previous animations
this.stopAnimations();
this.updatePosition(displayModel, diffData, onfinish);
}
@ -90,8 +92,6 @@ export default class Node extends Item {
renderExt.onZoom(this.shapeMap, this.zoom);
}
// terminate previous animations
this.stopAnimations();
// handle shape's and group's animate
if (!disableAnimate && animates) {
const animatesExcludePosition = getAnimatesExcludePosition(animates);
@ -114,7 +114,7 @@ export default class Node extends Item {
isReplace?: boolean,
theme?: {
styles: NodeStyleSet;
zoomStrategy: ZoomStrategyObj;
lodStrategy: lodStrategyObj;
},
onlyMove?: boolean,
onfinish?: Function,
@ -150,9 +150,9 @@ export default class Node extends Item {
this.animateFrameListener,
() => onfinish(displayModel.id),
);
}
return;
}
}
group.setLocalPosition(x, y, z);
}
@ -172,7 +172,7 @@ export default class Node extends Item {
}
const clonedModel = clone(this.model);
clonedModel.data.disableAnimate = disableAnimate;
return new Node({
const clonedNode = new Node({
model: clonedModel,
renderExtensions: this.renderExtensions,
containerGroup,
@ -180,10 +180,15 @@ export default class Node extends Item {
stateMapper: this.stateMapper,
zoom: this.zoom,
theme: {
styles: clone(this.themeStyles),
zoomStrategy: this.zoomStrategy,
styles: this.themeStyles,
lodStrategy: this.lodStrategy,
},
});
Object.keys(this.shapeMap).forEach((shapeId) => {
if (!this.shapeMap[shapeId].isVisible())
clonedNode.shapeMap[shapeId].hide();
});
return clonedNode;
}
public getAnchorPoint(point: Point) {

View File

@ -247,7 +247,6 @@ export class InteractionController {
private initEvents = () => {
Object.values(CANVAS_EVENT_TYPE).forEach((eventName) => {
// console.debug('Listen on canvas: ', eventName);
this.graph.canvas.document.addEventListener(
eventName,
this.handleCanvasEvent,
@ -262,13 +261,6 @@ export class InteractionController {
};
private handleCanvasEvent = (gEvent: FederatedPointerEvent) => {
// const debug = gEvent.type.includes('over') || gEvent.type.includes('move') ? () => {} : console.debug;
// Find the Node/Edge/Combo group element.
// const itemGroup = findItemGroup(gEvent.target as IElement);
// const itemType = itemGroup?.getAttribute('data-item-type') || 'canvas';
// const itemId = itemGroup?.getAttribute('data-item-id') || 'CANVAS';
const itemInfo = getItemInfoFromElement(gEvent.target as IElement);
if (!itemInfo) {
// This event was triggered from an element which belongs to none of the nodes/edges/canvas.
@ -313,7 +305,6 @@ export class InteractionController {
}
this.graph.emit(`${itemType}:${gEvent.type}`, event);
this.graph.emit(`${gEvent.type}`, event);
// debug(`Item ${event.type} :`, event);
}
};
@ -328,14 +319,11 @@ export class InteractionController {
const preType = prevItemInfo.itemType;
this.graph.emit(`${preType}:pointerleave`, {
...event,
itemId: prevItemInfo.itemId,
itemType: prevItemInfo.itemType,
type: 'pointerleave',
target: prevItemInfo.groupElement,
});
// console.debug(`${preType}:pointerleave`, {
// ...event,
// type: 'pointerleave',
// target: prevItemInfo.groupElement,
// });
}
if (curItemInfo) {
const curType = curItemInfo.itemType;
@ -344,11 +332,6 @@ export class InteractionController {
type: 'pointerenter',
target: curItemInfo.groupElement,
});
// console.debug(`${curType}:pointerenter`, {
// ...event,
// type: 'pointerenter',
// target: curItemInfo.groupElement,
// });
}
}
this.prevItemInfo = curItemInfo;

View File

@ -28,7 +28,7 @@ import {
ITEM_TYPE,
ShapeStyle,
SHAPE_TYPE,
ZoomStrategyObj,
lodStrategyObj,
} from '../../types/item';
import {
ThemeSpecification,
@ -39,7 +39,7 @@ import {
} from '../../types/theme';
import { DirectionalLight, AmbientLight } from '@antv/g-plugin-3d';
import { ViewportChangeHookParams } from '../../types/hook';
import { formatZoomStrategy } from '../../util/zoom';
import { formatLodStrategy } from '../../util/zoom';
/**
* Manages and stores the node / edge / combo items.
@ -125,6 +125,8 @@ export class ItemController {
);
this.graph.hooks.transientupdate.tap(this.onTransientUpdate.bind(this));
this.graph.hooks.viewportchange.tap(this.onViewportChange.bind(this));
this.graph.hooks.themechange.tap(this.onThemeChange.bind(this));
this.graph.hooks.destroy.tap(this.onDestroy.bind(this));
}
/**
@ -196,7 +198,7 @@ export class ItemController {
graph.canvas.appendChild(ambientLight);
graph.canvas.appendChild(light);
const { width, height } = graph.canvas.getConfig();
graph.canvas.getCamera().setPerspective(0.1, 5000, 45, width / height);
graph.canvas.getCamera().setPerspective(0.1, 50000, 45, width / height);
}
// 2. create node / edge / combo items, classes from ../../item, and element drawing and updating fns from node/edge/comboExtensions
@ -287,8 +289,8 @@ export class ItemController {
const { dataTypeField: nodeDataTypeField } = nodeTheme;
const edgeToUpdate = {};
const updateEdges = throttle(
() => {
Object.keys(edgeToUpdate).forEach((id) => {
(updateMap) => {
Object.keys(updateMap || edgeToUpdate).forEach((id) => {
const item = itemMap[id] as Edge;
if (item && !item.destroyed) item.forceUpdate();
});
@ -316,7 +318,15 @@ export class ItemController {
}
const node = itemMap[id] as Node;
const innerModel = graphCore.getNode(id);
node.onframe = updateEdges;
const relatedEdgeInnerModels = graphCore.getRelatedEdges(id);
const nodeRelatedToUpdate = {};
relatedEdgeInnerModels.forEach((edge) => {
edgeToUpdate[edge.id] = edge;
nodeRelatedToUpdate[edge.id] = edge;
});
node.onframe = () => updateEdges(nodeRelatedToUpdate);
node.update(
innerModel,
{ previous, current },
@ -328,10 +338,6 @@ export class ItemController {
node.onframe = undefined;
},
);
const relatedEdgeInnerModels = graphCore.getRelatedEdges(id);
relatedEdgeInnerModels.forEach((edge) => {
edgeToUpdate[edge.id] = edge;
});
});
updateEdges();
}
@ -461,6 +467,40 @@ export class ItemController {
false,
);
private onThemeChange = ({ theme }) => {
if (!theme) return;
const { nodeDataTypeSet, edgeDataTypeSet } = this;
const { node: nodeTheme, edge: edgeTheme } = theme;
Object.values(this.itemMap).forEach((item) => {
const itemTye = item.getType();
const usingTheme = itemTye === 'node' ? nodeTheme : edgeTheme;
const usingTypeSet =
itemTye === 'node' ? nodeDataTypeSet : edgeDataTypeSet;
const { dataTypeField } = usingTheme;
let dataType;
if (dataTypeField) dataType = item.model.data[dataTypeField] as string;
const itemTheme = getItemTheme(
usingTypeSet,
dataTypeField,
dataType,
usingTheme,
);
item.update(
item.model,
undefined,
false,
itemTheme as {
styles: NodeStyleSet;
lodStrategy: lodStrategyObj;
},
);
});
};
private onDestroy = () => {
Object.values(this.itemMap).forEach((item) => item.destroy());
};
private onTransientUpdate(param: {
type: ITEM_TYPE | SHAPE_TYPE;
id: ID;
@ -583,7 +623,7 @@ export class ItemController {
zoom,
theme: itemTheme as {
styles: NodeStyleSet;
zoomStrategy: ZoomStrategyObj;
lodStrategy: lodStrategyObj;
},
device:
graph.rendererType === 'webgl-3d'
@ -640,7 +680,7 @@ export class ItemController {
zoom,
theme: itemTheme as {
styles: EdgeStyleSet;
zoomStrategy: ZoomStrategyObj;
lodStrategy: lodStrategyObj;
},
});
});
@ -717,16 +757,16 @@ const getItemTheme = (
itemTheme: NodeThemeSpecifications | EdgeThemeSpecifications,
): {
styles: NodeStyleSet | EdgeStyleSet;
zoomStrategy: ZoomStrategyObj;
lodStrategy: lodStrategyObj;
} => {
const { styles: themeStyles, zoomStrategy } = itemTheme;
const formattedZoomStrategy = formatZoomStrategy(zoomStrategy);
const { styles: themeStyles, lodStrategy } = itemTheme;
const formattedLodStrategy = formatLodStrategy(lodStrategy);
if (!dataTypeField) {
// dataType field is not assigned
const styles = isArray(themeStyles)
? themeStyles[0]
: Object.values(themeStyles)[0];
return { styles, zoomStrategy: formattedZoomStrategy };
return { styles, lodStrategy: formattedLodStrategy };
}
dataTypeSet.add(dataType as string);
let themeStyle;
@ -739,6 +779,6 @@ const getItemTheme = (
}
return {
styles: themeStyle,
zoomStrategy: formattedZoomStrategy,
lodStrategy: formattedLodStrategy,
};
};

View File

@ -55,6 +55,8 @@ export class LayoutController {
const { graphCore, options } = params;
this.graph.emit('startlayout');
if (isImmediatelyInvokedLayoutOptions(options)) {
const {
animated = false,
@ -145,6 +147,8 @@ export class LayoutController {
}
}
this.graph.emit('endlayout');
// Update nodes' positions.
this.updateNodesPosition(positions);
}

View File

@ -29,6 +29,7 @@ export class ThemeController {
this.extension = this.getExtension();
this.themes = this.getThemes();
this.graph.hooks.init.tap(this.onInit.bind(this));
this.graph.hooks.themechange.tap(this.onThemeChange.bind(this));
}
/**
@ -59,4 +60,10 @@ export class ThemeController {
Object.keys(canvas).forEach((key) => (dom.style[key] = canvas[key]));
}
}
private onThemeChange({ canvases }) {
if (!canvases) return;
this.extension = this.getExtension();
this.onInit({ canvases });
}
}

View File

@ -48,11 +48,11 @@ export class ViewportController {
}> = {};
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];
const { dx = 0, dy = 0, dz = 0 } = translate;
const [px, py, pz] = camera.getPosition();
const [fx, fy, fz] = camera.getFocalPoint();
landmarkOptions.position = [px - dx, py - dy, pz - dz];
landmarkOptions.focalPoint = [fx - dx, fy - dy, fz - dz];
}
if (zoom) {
@ -73,6 +73,7 @@ export class ViewportController {
`mark${landmarkCounter}`,
landmarkOptions,
);
return new Promise((resolve) => {
transientCamera.gotoLandmark(transientLandmark, {
duration: Number(duration),

View File

@ -11,11 +11,7 @@ import {
Specification,
} from '../types';
import { CameraAnimationOptions } from '../types/animate';
import {
BehaviorObjectOptionsOf,
BehaviorOptionsOf,
BehaviorRegistry,
} from '../types/behavior';
import { BehaviorOptionsOf, BehaviorRegistry } from '../types/behavior';
import { ComboModel } from '../types/combo';
import { Padding, Point } from '../types/common';
import { DataChangeType, GraphCore } from '../types/data';
@ -29,9 +25,13 @@ import {
} from '../types/layout';
import { NodeModel, NodeModelData } from '../types/node';
import { RendererName } from '../types/render';
import { ThemeRegistry, ThemeSpecification } from '../types/theme';
import {
ThemeOptionsOf,
ThemeRegistry,
ThemeSpecification,
} from '../types/theme';
import { FitViewRules, GraphTransformOptions } from '../types/view';
import { createCanvas } from '../util/canvas';
import { changeRenderer, createCanvas } from '../util/canvas';
import { formatPadding } from '../util/shape';
import {
DataController,
@ -187,6 +187,16 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
).then(() => (this.canvasReady = true));
}
/**
* Change the renderer at runtime.
* @param type renderer name
* @returns
*/
public changeRenderer(type) {
this.rendererType = type || 'canvas';
changeRenderer(this.rendererType, this.canvas);
}
/**
* Initialize the hooks for graph's lifecycles.
*/
@ -249,6 +259,14 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
| { key: string; type: string; [cfgName: string]: unknown }
)[];
}>({ name: 'pluginchange' }),
themechange: new Hook<{
theme: ThemeSpecification;
canvases: {
background: Canvas;
main: Canvas;
transient: Canvas;
};
}>({ name: 'init' }),
destroy: new Hook<{}>({ name: 'destroy' }),
};
}
@ -256,10 +274,30 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
/**
* Update the specs(configurations).
*/
public updateSpecification(spec: Specification<B, T>) {
public updateSpecification(spec: Specification<B, T>): Specification<B, T> {
return Object.assign(this.specification, spec);
}
/**
* Update the theme specs (configurations).
*/
public updateTheme(theme: ThemeOptionsOf<T>) {
this.specification.theme = theme;
// const { specification } = this.themeController;
// notifiying the themeController
this.hooks.themechange.emit({
canvases: {
background: this.backgroundCanvas,
main: this.canvas,
transient: this.transientCanvas,
},
});
// theme is formatted by themeController, notify the item controller to update the items
this.hooks.themechange.emit({
theme: this.themeController.specification,
});
}
/**
* Get the copy of specs(configurations).
* @returns graph specs
@ -358,16 +396,16 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
* @param effectTiming animation configurations
*/
public async translate(
dx: number,
dy: number,
distance: Partial<{
dx: number;
dy: number;
dz: number;
}>,
effectTiming?: CameraAnimationOptions,
) {
await this.transform(
{
translate: {
dx,
dy,
},
translate: distance,
},
effectTiming,
);
@ -383,7 +421,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
effectTiming?: CameraAnimationOptions,
) {
const { x: cx, y: cy } = this.getViewportCenter();
await this.translate(cx - x, cy - y, effectTiming);
await this.translate({ dx: cx - x, dy: cy - y }, effectTiming);
}
/**
@ -1210,7 +1248,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
* @returns
* @group Interaction
*/
public updateBehavior(behavior: BehaviorObjectOptionsOf<B>, mode?: string) {
public updateBehavior(behavior: BehaviorOptionsOf<B>, mode?: string) {
this.hooks.behaviorchange.emit({
action: 'update',
modes: [mode],
@ -1377,10 +1415,14 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
* @returns
* @group Graph Instance
*/
public destroy() {
public destroy(callback?: Function) {
// TODO: call the destroy functions after items' buildOut animations finished
setTimeout(() => {
this.canvas.destroy();
this.backgroundCanvas.destroy();
this.transientCanvas.destroy();
callback?.();
}, 500);
this.hooks.destroy.emit({});

View File

@ -21,31 +21,31 @@ interface ActivateRelationsOptions {
* Defaults to true.
* If set to false, `trigger` options will be ignored.
*/
multiple: boolean;
multiple?: boolean;
/**
* The key to pressed with mouse click to apply multiple selection.
* Defaults to `"click"`.
* Could be "click", "mouseenter".
*/
trigger: Trigger;
trigger?: Trigger;
/**
*
* Defaults to `"selected"`.
*
*/
activeState: 'selected';
activeState?: 'selected';
/**
* Whether allow the behavior happen on the current item.
*/
shouldBegin: (event: IG6GraphEvent) => boolean;
shouldBegin?: (event: IG6GraphEvent) => boolean;
/**
* Whether to update item state.
* If it returns false, you may probably listen to `eventName` and
* manage states or data manually
*/
shouldUpdate: (event: IG6GraphEvent) => boolean;
shouldUpdate?: (event: IG6GraphEvent) => boolean;
}
const DEFAULT_OPTIONS: ActivateRelationsOptions = {

View File

@ -123,13 +123,9 @@ export default class BrushSelect extends Behavior {
getEvents = () => {
return {
// 'dragstart': this.onMouseDown,
// 'drag': this.onMouseMove,
// 'dragend': this.onMouseUp,
'canvas:pointerdown': this.onMouseDown,
'canvas:pointermove': this.onMouseMove,
'canvas:pointerup': this.onMouseUp,
pointerdown: this.onMouseDown,
pointermove: this.onMouseMove,
pointerup: this.onMouseUp,
};
};
@ -147,16 +143,19 @@ export default class BrushSelect extends Behavior {
public onMouseDown(event: IG6GraphEvent) {
if (!this.options.shouldBegin(event)) return;
const { itemId, canvas } = event;
// should not begin at an item
if (itemId) return;
const { itemId, itemType, canvas } = event;
// should not begin at node
if (itemId && itemType === 'node') return;
this.beginPoint = {
x: canvas.x,
y: canvas.y,
};
if (!this.isKeydown(event as any)) return;
if (!this.isKeydown(event as any)) {
this.clearStates();
return;
}
const { brush } = this;
if (!brush) {

View File

@ -1,5 +1,7 @@
import { ID } from '@antv/graphlib';
import { Behavior } from '../../types/behavior';
import { IG6GraphEvent } from '../../types/event';
import { Point } from '../../types/common';
const ALLOWED_TRIGGERS = ['shift', 'ctrl', 'alt', 'meta'] as const;
type Trigger = (typeof ALLOWED_TRIGGERS)[number];
@ -19,7 +21,7 @@ interface ClickSelectOptions {
trigger: Trigger;
/**
* Item types to be able to select.
* Defaults to `["nodes"]`.
* Defaults to `["node"]`.
* Should be an array of "node", "edge", or "combo".
*/
itemTypes: Array<'node' | 'edge' | 'combo'>;
@ -49,7 +51,7 @@ const DEFAULT_OPTIONS: ClickSelectOptions = {
multiple: true,
trigger: 'shift',
selectedState: 'selected',
itemTypes: ['node'],
itemTypes: ['node', 'edge'],
eventName: '',
shouldBegin: () => true,
shouldUpdate: () => true,
@ -57,6 +59,15 @@ const DEFAULT_OPTIONS: ClickSelectOptions = {
export default class ClickSelect extends Behavior {
options: ClickSelectOptions;
/**
* Cache the ids of items selected by this behavior
*/
private selectedIds: ID[] = [];
/**
* Two flag to avoid onCanvasClick triggered while dragging canvas
*/
private canvasPointerDown: Point | undefined = undefined;
private canvasPointerMove: Boolean = false;
constructor(options: Partial<ClickSelectOptions>) {
super(Object.assign({}, DEFAULT_OPTIONS, options));
@ -73,7 +84,9 @@ export default class ClickSelect extends Behavior {
return {
'node:click': this.onClick,
'edge:click': this.onClick,
'canvas:click': this.onCanvasClick,
'canvas:pointerdown': this.onCanvasPointerDown,
'canvas:pointermove': this.onCanvasPointerMove,
'canvas:pointerup': this.onCanvasPointerUp,
};
};
@ -111,17 +124,15 @@ export default class ClickSelect extends Behavior {
// Select/Unselect item.
if (this.options.shouldUpdate(event)) {
if (multiple) {
this.graph.setItemState(itemId, state, isSelectAction);
} else {
if (!multiple) {
// Not multiple, clear all currently selected items
const selectedItemIds = [
...this.graph.findIdByState('node', state),
...this.graph.findIdByState('edge', state),
...this.graph.findIdByState('combo', state),
];
this.graph.setItemState(selectedItemIds, state, false);
this.graph.setItemState(this.selectedIds, state, false);
}
this.graph.setItemState(itemId, state, isSelectAction);
if (isSelectAction) {
this.selectedIds.push(itemId);
} else {
this.selectedIds.filter((id) => id !== itemId);
}
}
@ -141,21 +152,38 @@ export default class ClickSelect extends Behavior {
}
}
public onCanvasPointerDown(event: IG6GraphEvent) {
this.canvasPointerDown = {
x: event.canvas.x,
y: event.canvas.y,
};
this.canvasPointerMove = false;
}
public onCanvasPointerMove(event: IG6GraphEvent) {
if (this.canvasPointerDown) {
const deltaX = Math.abs(this.canvasPointerDown.x - event.canvas.x);
const deltaY = Math.abs(this.canvasPointerDown.y - event.canvas.y);
if (deltaX > 1 || deltaY > 1) this.canvasPointerMove = true;
}
}
public onCanvasPointerUp(event: IG6GraphEvent) {
if (this.canvasPointerDown && !this.canvasPointerMove)
this.onCanvasClick(event);
this.canvasPointerDown = undefined;
this.canvasPointerMove = false;
}
public onCanvasClick(event: IG6GraphEvent) {
if (!this.options.shouldBegin(event)) return;
// Find current selected items.
const state = this.options.selectedState;
const selectedItemIds = [
...this.graph.findIdByState('node', state),
...this.graph.findIdByState('edge', state),
...this.graph.findIdByState('combo', state),
];
if (!selectedItemIds.length) return;
if (!this.selectedIds.length) return;
// Unselect all items.
if (this.options.shouldUpdate(event)) {
this.graph.setItemState(selectedItemIds, state, false);
this.graph.setItemState(this.selectedIds, state, false);
}
// Emit an event.

View File

@ -24,19 +24,24 @@ export interface DragCanvasOptions {
* The assistant secondary key on keyboard. If it is not assigned, the behavior will be triggered when trigger happens.
*/
secondaryKey?: string;
/**
* The assistant secondary key on keyboard to prevent the behavior to be tiggered. 'shift' by default.
*/
secondaryKeyToDisable?: string;
/**
* The key on keyboard to speed up translating while pressing and drag-canvas by direction keys. The trigger should be 'directionKeys' for this option.
*/
speedUpKey?: string;
/**
* The range of canvas to limit dragging, 0 by default, which means the graph cannot be dragged totally out of the view port range.
* If scalableRange > 0, the graph can be dragged out of the view port range.
* If scalableRange is number or a string without 'px', means it is a ratio of the graph content.
* If scalableRange is a string with 'px', it is regarded as pixels.
* If scalableRange = 0, no constrains;
* If scalableRange > 0, the graph can be dragged out of the view port range
* If scalableRange < 0, the range is smaller than the view port.
* If 0 < abs(scalableRange) < 1, it is regarded as a ratio of view port size.
* If abs(scalableRange) > 1, it is regarded as pixels.
* Refer to https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*IFfoS67_HssAAAAAAAAAAAAAARQnAQ
*/
scalableRange?: number;
scalableRange?: string | number;
/**
* The event name to trigger when drag end.
*/
@ -48,11 +53,12 @@ export interface DragCanvasOptions {
}
const DEFAULT_OPTIONS: Required<DragCanvasOptions> = {
enableOptimize: true,
enableOptimize: false,
dragOnItems: false,
trigger: 'drag',
direction: 'both',
secondaryKey: '',
secondaryKeyToDisable: 'shift',
speedUpKey: '',
scalableRange: 0,
eventName: '',
@ -66,6 +72,7 @@ export default class DragCanvas extends Behavior {
private dragging: boolean; // pointerdown + pointermove a distance
private keydown: boolean;
private speedupKeydown: boolean;
private disableKeydown: boolean;
private hiddenEdgeIds: ID[];
private hiddenNodeIds: ID[];
@ -81,6 +88,10 @@ export default class DragCanvas extends Behavior {
}
getEvents() {
if (typeof window !== 'undefined') {
window.addEventListener('keydown', this.onKeydown.bind(this));
window.addEventListener('keyup', this.onKeyup.bind(this));
}
if (this.options.trigger === 'directionKeys') {
return {
keydown: this.onKeydown,
@ -97,7 +108,10 @@ export default class DragCanvas extends Behavior {
}
public onPointerDown(event) {
const { secondaryKey, dragOnItems, shouldBegin } = this.options;
const { secondaryKey, secondaryKeyToDisable, dragOnItems, shouldBegin } =
this.options;
// disabled key is pressing
if (secondaryKeyToDisable && this.disableKeydown) return;
// assistant key is not pressing
if (secondaryKey && !this.keydown) return;
// should not begin
@ -145,12 +159,25 @@ export default class DragCanvas extends Behavior {
const { scalableRange, direction } = this.options;
const [width, height] = graph.getSize();
const graphBBox = graph.canvas.getRoot().getRenderBounds();
let expandWidth = scalableRange;
let expandHeight = scalableRange;
// 若 scalableRange 是 0~1 的小数,则作为比例考虑
if (expandWidth < 1 && expandWidth > -1) {
expandWidth = width * expandWidth;
expandHeight = height * expandHeight;
let rangeNum = Number(scalableRange);
let isPixel;
if (typeof scalableRange === 'string') {
if (scalableRange.includes('px')) {
isPixel = scalableRange.includes('px');
rangeNum = Number(scalableRange.replace('px', ''));
}
if (scalableRange.includes('%')) {
rangeNum = Number(scalableRange.replace('%', '')) / 100;
}
}
if (rangeNum === 0) return { dx: diffX, dy: diffY };
let expandWidth = rangeNum;
let expandHeight = rangeNum;
// If it is not a string with 'px', regard as ratio
if (!isPixel) {
expandWidth = width * rangeNum;
expandHeight = height * rangeNum;
}
const leftTopClient = graph.getViewportByCanvas({
x: graphBBox.min[0],
@ -182,9 +209,11 @@ export default class DragCanvas extends Behavior {
public onPointerMove(event) {
if (!this.pointerDownAt) return;
const { eventName, direction, secondaryKeyToDisable } = this.options;
// disabled key is pressing
if (secondaryKeyToDisable && this.disableKeydown) return;
const { graph } = this;
const { client } = event;
const { eventName, direction } = this.options;
const diffX = client.x - this.pointerDownAt.x;
const diffY = client.y - this.pointerDownAt.y;
if (direction === 'x' && !diffX) return;
@ -198,7 +227,7 @@ export default class DragCanvas extends Behavior {
}
const { dx, dy } = this.formatDisplacement(diffX, diffY);
graph.translate(dx, dy);
graph.translate({ dx, dy });
this.pointerDownAt = { x: client.x, y: client.y };
@ -228,16 +257,26 @@ export default class DragCanvas extends Behavior {
public onKeydown(event) {
const { key } = event;
const { secondaryKey, trigger, speedUpKey, eventName, shouldBegin } =
this.options;
const {
secondaryKey,
secondaryKeyToDisable,
trigger,
speedUpKey,
eventName,
shouldBegin,
} = this.options;
if (secondaryKey && secondaryKey === key.toLowerCase()) {
this.keydown = true;
}
if (speedUpKey && speedUpKey === key.toLowerCase()) {
this.speedupKeydown = true;
}
if (secondaryKeyToDisable && secondaryKeyToDisable === key.toLowerCase()) {
this.disableKeydown = true;
}
if (trigger === 'directionKeys') {
if (secondaryKey && !this.keydown) return;
if (secondaryKeyToDisable && this.disableKeydown) return;
if (!shouldBegin(event)) return;
const { graph, speedupKeydown } = this;
const speed = speedupKeydown ? 20 : 1;
@ -262,7 +301,7 @@ export default class DragCanvas extends Behavior {
dx,
dy,
);
graph.translate(formattedDx, formattedDy);
graph.translate({ dx: formattedDx, dy: formattedDy });
if (eventName) {
this.graph.emit(eventName, {
translate: { dx: formattedDx, dy: formattedDy },
@ -274,12 +313,21 @@ export default class DragCanvas extends Behavior {
public onKeyup(event) {
const { key } = event;
const { secondaryKey, speedUpKey } = this.options;
const { secondaryKey, secondaryKeyToDisable, speedUpKey } = this.options;
if (secondaryKey && secondaryKey === key.toLowerCase()) {
this.keydown = false;
}
if (speedUpKey && speedUpKey === key.toLowerCase()) {
this.speedupKeydown = false;
}
if (secondaryKeyToDisable && secondaryKeyToDisable === key.toLowerCase()) {
this.disableKeydown = false;
}
}
public destroy() {
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', this.onKeydown.bind(this));
window.removeEventListener('keyup', this.onKeyup.bind(this));
}
}
}

View File

@ -3,6 +3,7 @@ import { throttle, uniq } from '@antv/util';
import { EdgeModel } from '../../types';
import { Behavior } from '../../types/behavior';
import { IG6GraphEvent } from '../../types/event';
import { Point } from '../../types/common';
const DELEGATE_SHAPE_ID = 'g6-drag-node-delegate-shape';
@ -17,12 +18,12 @@ export interface DragNodeOptions {
* Ignored when enableDelegate is true.
* Defaults to true.
*/
enableTransient?: boolean;
enableTransient?: Boolean;
/**
* Whether to use a virtual rect moved with the dragging mouse instead of the node.
* Defaults to false.
*/
enableDelegate?: boolean;
enableDelegate?: Boolean;
/**
* The drawing properties when the nodes are dragged.
* Only used when enableDelegate is true.
@ -46,7 +47,7 @@ export interface DragNodeOptions {
* Ignored when enableTransient or enableDelegate is true.
* Defaults to false.
*/
hideRelatedEdges?: boolean;
hideRelatedEdges?: Boolean;
/**
* The state name to be considered as "selected".
* Defaults to "selected".
@ -79,7 +80,7 @@ const DEFAULT_OPTIONS: Required<DragNodeOptions> = {
shouldBegin: () => true,
};
export class DragNode extends Behavior {
export default class DragNode extends Behavior {
options: DragNodeOptions;
// Private states
@ -96,6 +97,8 @@ export class DragNode extends Behavior {
minY?: number;
maxY?: number;
}> = [];
private pointerDown: Point | undefined = undefined;
private dragging: Boolean = false;
constructor(options: Partial<DragNodeOptions>) {
const finalOptions = Object.assign({}, DEFAULT_OPTIONS, options);
@ -131,6 +134,20 @@ export class DragNode extends Behavior {
public onPointerDown(event: IG6GraphEvent) {
if (!this.options.shouldBegin(event)) return;
this.pointerDown = { x: event.canvas.x, y: event.canvas.y };
this.dragging = false;
}
public onPointerMove(event: IG6GraphEvent) {
if (!this.pointerDown) return;
const beginDeltaX = Math.abs(this.pointerDown.x - event.canvas.x);
const beginDeltaY = Math.abs(this.pointerDown.y - event.canvas.y);
if (beginDeltaX < 1 && beginDeltaY < 1) return;
// pointerDown + first move = dragging
if (!this.dragging) {
this.dragging = true;
const currentNodeId = event.itemId;
let selectedNodeIds = this.graph.findIdByState(
'node',
@ -140,11 +157,15 @@ export class DragNode extends Behavior {
// If current node is selected, drag all the selected nodes together.
// Otherwise drag current node.
if (!selectedNodeIds.includes(currentNodeId)) {
if (currentNodeId && !selectedNodeIds.includes(currentNodeId)) {
selectedNodeIds = [currentNodeId];
}
this.originPositions = selectedNodeIds.map((id) => {
if (!this.graph.getNodeData(id)) {
console.log('node does not exist', id);
return;
}
const { x, y } = this.graph.getNodeData(id).data as {
x: number;
y: number;
@ -204,12 +225,11 @@ export class DragNode extends Behavior {
}
// @ts-ignore FIXME: Type
this.originX = event.client.x;
this.originX = event.canvas.x;
// @ts-ignore FIXME: Type
this.originY = event.client.y;
this.originY = event.canvas.y;
}
public onPointerMove(event: IG6GraphEvent) {
if (!this.originPositions.length) {
return;
}
@ -217,9 +237,9 @@ export class DragNode extends Behavior {
// @ts-ignore FIXME: type
const pointerEvent = event as PointerEvent;
// @ts-ignore FIXME: Type
const deltaX = pointerEvent.client.x - this.originX;
const deltaX = pointerEvent.canvas.x - this.originX;
// @ts-ignore FIXME: Type
const deltaY = pointerEvent.client.y - this.originY;
const deltaY = pointerEvent.canvas.y - this.originY;
if (this.options.enableDelegate) {
this.moveDelegate(deltaX, deltaY);
@ -230,7 +250,7 @@ export class DragNode extends Behavior {
}
}
public moveNodes(deltaX: number, deltaY: number, transient: boolean) {
public moveNodes(deltaX: number, deltaY: number, transient: Boolean) {
const positionChanges = this.originPositions.map(({ id, x, y }) => {
return {
id,
@ -262,7 +282,7 @@ export class DragNode extends Behavior {
public throttledMoveNodes: Function = (
deltaX: number,
deltaY: number,
transient: boolean,
transient: Boolean,
) => {
// Should be overrided when drag start.
};
@ -325,6 +345,8 @@ export class DragNode extends Behavior {
}
public onPointerUp(event: IG6GraphEvent) {
this.pointerDown = undefined;
this.dragging = false;
const enableTransient =
this.options.enableTransient && this.graph.rendererType !== 'webgl-3d';
// If transient or delegate was enabled, move the real nodes.
@ -332,9 +354,9 @@ export class DragNode extends Behavior {
// @ts-ignore FIXME: type
const pointerEvent = event as PointerEvent;
// @ts-ignore FIXME: Type
const deltaX = pointerEvent.client.x - this.originX;
const deltaX = pointerEvent.canvas.x - this.originX;
// @ts-ignore FIXME: Type
const deltaY = pointerEvent.client.y - this.originY;
const deltaY = pointerEvent.canvas.y - this.originY;
this.moveNodes(deltaX, deltaY, false);
}

View File

@ -0,0 +1,104 @@
import { ID } from '@antv/graphlib';
import { Behavior } from '../../types/behavior';
import { IG6GraphEvent } from '../../types/event';
import { ITEM_TYPE } from 'types/item';
// TODO: Combo related features:
// hover combo
export interface HoverActivateOptions {
/**
* The time in milliseconds to throttle moving. Useful to avoid the frequent calculation.
* Defaults to 0.
*/
throttle?: number;
/**
* The state name to be considered as "selected".
* Defaults to "selected".
*/
activateState?: string;
/**
* Item types to be able to acitvate.
* Defaults to `["node", "edge"]`.
* Should be an array of "node", "edge", or "combo".
*/
itemTypes: Array<'node' | 'edge' | 'combo'>;
/**
* The event name to trigger when drag end.
*/
eventName?: string;
/**
* Whether allow the behavior happen on the current item.
*/
shouldBegin?: (event: IG6GraphEvent) => boolean;
}
const DEFAULT_OPTIONS: Required<HoverActivateOptions> = {
throttle: 16,
activateState: 'active',
eventName: '',
itemTypes: ['node', 'edge', 'combo'],
shouldBegin: () => true,
};
export class HoverActivate extends Behavior {
options: HoverActivateOptions;
private currentItemInfo: { id: ID; itemType: ITEM_TYPE };
constructor(options: Partial<HoverActivateOptions>) {
const finalOptions = Object.assign({}, DEFAULT_OPTIONS, options);
super(finalOptions);
}
getEvents = () => {
return {
'node:pointerenter': this.onPointerEnter,
'node:pointerleave': this.onPointerLeave,
'edge:pointerenter': this.onPointerEnter,
'edge:pointerleave': this.onPointerLeave,
};
};
public onPointerEnter(event: IG6GraphEvent) {
const { itemId, itemType } = event;
const { graph, currentItemInfo } = this;
const { activateState, itemTypes, eventName } = this.options;
if (currentItemInfo && itemTypes.includes(currentItemInfo.itemType)) {
graph.setItemState(currentItemInfo.id, activateState, false);
}
if (!itemTypes.includes(itemType as ITEM_TYPE)) return;
graph.setItemState(itemId, activateState, true);
this.currentItemInfo = { id: itemId, itemType: itemType as ITEM_TYPE };
// Emit event.
if (eventName) {
this.graph.emit(eventName, {
itemId,
itemType,
state: activateState,
action: 'pointerenter',
});
}
}
public onPointerLeave(event: IG6GraphEvent) {
const { itemId, itemType } = event;
const { activateState, itemTypes, eventName } = this.options;
if (!itemTypes.includes(itemType as ITEM_TYPE)) return;
const { graph } = this;
graph.setItemState(itemId, activateState, false);
this.currentItemInfo = undefined;
// Emit event.
if (eventName) {
this.graph.emit(eventName, {
itemId,
itemType,
state: activateState,
action: 'pointerleave',
});
}
}
}

View File

@ -53,7 +53,7 @@ export default class LassoSelect extends BrushSelect {
return this.points;
}
publiccreateBrush() {
public createBrush() {
const { graph, options } = this;
const { brushStyle } = options;
return graph.drawTransient('path', LASSO_SHAPE_ID, {

View File

@ -1,7 +1,4 @@
import { ICamera } from '@antv/g';
import { ID } from '@antv/graphlib';
import { debounce, uniq } from '@antv/util';
import { EdgeModel } from '../../types';
import { Behavior } from '../../types/behavior';
import { IG6GraphEvent } from '../../types/event';
import { Point } from '../../types/common';

View File

@ -31,6 +31,14 @@ export interface ZoomCanvas3DOptions {
* The event name to trigger when drag end.
*/
eventName?: string;
/**
* The min value of camera's dolly to constrain the zoom-canvas-3d behavior
*/
minZoom?: number;
/**
* The max value of camera's dolly to constrain the zoom-canvas-3d behavior
*/
maxZoom?: number;
/**
* Whether allow the behavior happen on the current item.
*/
@ -41,8 +49,10 @@ const DEFAULT_OPTIONS: Required<ZoomCanvas3DOptions> = {
trigger: 'wheel',
secondaryKey: '',
eventName: '',
sensitivity: 1,
triggerOnItems: false,
sensitivity: 10,
triggerOnItems: true,
minZoom: 0.01,
maxZoom: 10,
shouldBegin: () => true,
};
@ -70,6 +80,17 @@ export default class ZoomCanvas3D extends Behavior {
}
getEvents = () => {
this.graph.canvas
.getContextService()
.getDomElement()
.addEventListener(
'wheel',
(e) => {
e.preventDefault();
},
{ passive: false },
);
if (this.options.trigger === 'wheel') {
return {
wheel: this.onWheel,
@ -89,6 +110,8 @@ export default class ZoomCanvas3D extends Behavior {
secondaryKey,
triggerOnItems,
eventName,
minZoom,
maxZoom,
sensitivity = 1,
shouldBegin,
} = options;
@ -96,10 +119,28 @@ export default class ZoomCanvas3D extends Behavior {
if (!shouldBegin(event)) return;
if (secondaryKey && !this.keydown) return;
const camera = graph.canvas.getCamera();
const sign = event.deltaY > 0 ? 1 : -1;
const currentDistance = camera.getDistance();
const dolly =
((100 * sign * sensitivity) / currentDistance) *
Math.sqrt(currentDistance);
const toDistance = currentDistance + dolly;
const cameraFrontOfFocalPoint = camera.getDistanceVector()[2] < 0;
console.log(event.deltaY);
// zoom out constraint
if (
dolly > 0 &&
cameraFrontOfFocalPoint &&
toDistance > (1 / minZoom) * 200
) {
return;
}
// zoom in constraint
if (dolly < 0 && !cameraFrontOfFocalPoint && toDistance > maxZoom * 200) {
return;
}
camera.dolly(event.deltaY * sensitivity);
camera.dolly(dolly);
// Emit event.
if (eventName) {

View File

@ -11,7 +11,7 @@ export interface ZoomCanvasOptions {
/**
* Whether allow trigger this behavior when wheeling start on nodes / edges / combos.
*/
zoomOnItems?: boolean;
triggerOnItems?: boolean;
/**
* The trigger for the behavior, 'wheel' by default. 'upDownKeys' means trigger this behavior by up / down keys on keyboard.
*/
@ -32,6 +32,14 @@ export interface ZoomCanvasOptions {
* The event name to trigger when zoom end.
*/
eventName?: string;
/**
* The min value of zoom ratio to constrain the zoom-canvas-3d behavior
*/
minZoom?: number;
/**
* The max value of zoom ratio to constrain the zoom-canvas-3d behavior
*/
maxZoom?: number;
/**
* Whether allow the behavior happen on the current item.
*/
@ -49,12 +57,14 @@ export interface ZoomCanvasOptions {
const DEFAULT_OPTIONS: Required<ZoomCanvasOptions> = {
enableOptimize: false,
zoomOnItems: false,
sensitivity: 1,
triggerOnItems: true,
sensitivity: 2,
trigger: 'wheel',
secondaryKey: '',
speedUpKey: 'shift',
eventName: '',
minZoom: 0.00001,
maxZoom: 1000,
shouldBegin: () => true,
};
@ -80,6 +90,17 @@ export default class ZoomCanvas extends Behavior {
}
getEvents() {
this.graph.canvas
.getContextService()
.getDomElement()
.addEventListener(
'wheel',
(e) => {
e.preventDefault();
},
{ passive: false },
);
if (this.options.trigger === 'upDownKeys') {
return {
keydown: this.onKeydown,
@ -137,15 +158,22 @@ export default class ZoomCanvas extends Behavior {
public onWheel(event) {
const { graph, keydown } = this;
const { deltaY, canvas, itemId } = event;
const { eventName, sensitivity, secondaryKey, zoomOnItems, shouldBegin } =
this.options;
const { deltaY, client, itemId } = event;
const {
eventName,
sensitivity,
secondaryKey,
triggerOnItems,
minZoom,
maxZoom,
shouldBegin,
} = this.options;
// TODO: CANVAS
const isOnItem = itemId && itemId !== 'CANVAS';
if (
(secondaryKey && !keydown) ||
(isOnItem && !zoomOnItems) ||
(isOnItem && !triggerOnItems) ||
!shouldBegin(event)
) {
this.endZoom();
@ -160,8 +188,11 @@ export default class ZoomCanvas extends Behavior {
let zoomRatio = 1;
if (deltaY < 0) zoomRatio = (100 + sensitivity) / 100;
if (deltaY > 0) zoomRatio = 100 / (100 + sensitivity);
const zoomTo = zoomRatio * graph.getZoom();
if (minZoom && zoomTo < minZoom) return;
if (maxZoom && zoomTo > maxZoom) return;
// TODO: the zoom center is wrong?
graph.zoom(zoomRatio, { x: canvas.x, y: canvas.y });
graph.zoom(zoomRatio, { x: client.x, y: client.y });
clearTimeout(this.zoomTimer);
this.zoomTimer = setTimeout(() => {

View File

@ -5,7 +5,7 @@ import BrushSelect from './behavior/brush-select';
import ClickSelect from './behavior/click-select';
import DragCanvas from './behavior/drag-canvas';
import LassoSelect from './behavior/lasso-select';
import { DragNode } from './behavior/drag-node';
import DragNode from './behavior/drag-node';
import { comboFromNode } from './data/comboFromNode';
import { LineEdge } from './item/edge';
import { CircleNode, SphereNode } from './item/node';
@ -23,6 +23,7 @@ import ZoomCanvas3D from './behavior/zoom-canvas-3d';
import RotateCanvas3D from './behavior/rotate-canvas-3d';
import TrackCanvas3D from './behavior/track-canvas-3d';
import OrbitCanvas3D from './behavior/orbit-canvas-3d';
import { HoverActivate } from './behavior/hover-activate';
const stdLib = {
transforms: {
@ -40,6 +41,7 @@ const stdLib = {
behaviors: {
'activate-relations': ActivateRelations,
'drag-canvas': DragCanvas,
'hover-activate': HoverActivate,
'zoom-canvas': ZoomCanvas,
'drag-node': DragNode,
'click-select': ClickSelect,

View File

@ -17,7 +17,7 @@ import {
SHAPE_TYPE,
ShapeStyle,
State,
ZoomStrategyObj,
lodStrategyObj,
} from '../../../types/item';
import {
LOCAL_BOUNDS_DIRTY_FLAG_KEY,
@ -37,7 +37,7 @@ export abstract class BaseEdge {
mergedStyles: EdgeShapeStyles;
sourcePoint: Point;
targetPoint: Point;
zoomStrategy?: ZoomStrategyObj;
lodStrategy?: lodStrategyObj;
labelPosition: {
x: number;
y: number;
@ -54,8 +54,6 @@ export abstract class BaseEdge {
private zoomCache: {
// the id of shapes which are hidden by zoom changing.
hiddenShape: { [shapeId: string]: boolean };
// timeout timer for scaling shapes with balanceRatio, simulates debounce in function.
balanceTimer: NodeJS.Timeout;
// the ratio to scale the size of shapes whose visual size should be kept, e.g. label and badges.
balanceRatio: number;
// last responsed zoom ratio.
@ -70,24 +68,28 @@ export abstract class BaseEdge {
wordWrapWidth: number;
// animate configurations for zoom level changing
animateConfig: AnimateCfg;
// the tag of first rendering
firstRender: boolean;
} = {
hiddenShape: {},
balanceRatio: 1,
zoom: 1,
zoomLevel: 0,
balanceTimer: undefined,
levelShapes: {},
wordWrapWidth: 50,
animateConfig: DEFAULT_ANIMATE_CFG.zoom,
firstRender: true,
};
constructor(props) {
const { themeStyles, zoomStrategy } = props;
const { themeStyles, lodStrategy, zoom } = props;
if (themeStyles) this.themeStyles = themeStyles;
this.zoomStrategy = zoomStrategy;
this.lodStrategy = lodStrategy;
this.boundsCache = {};
this.zoomCache.zoom = zoom;
this.zoomCache.balanceRatio = 1 / zoom;
this.zoomCache.animateConfig = {
...DEFAULT_ANIMATE_CFG.zoom,
...zoomStrategy?.animateCfg,
...lodStrategy?.animateCfg,
};
}
public mergeStyles(model: EdgeDisplayModel) {
@ -137,10 +139,10 @@ export abstract class BaseEdge {
const { levelShapes, zoom } = this.zoomCache;
Object.keys(shapeMap).forEach((shapeId) => {
const { showLevel } = shapeMap[shapeId].attributes;
if (showLevel !== undefined) {
levelShapes[showLevel] = levelShapes[showLevel] || [];
levelShapes[showLevel].push(shapeId);
const { lod } = shapeMap[shapeId].attributes;
if (lod !== undefined) {
levelShapes[lod] = levelShapes[lod] || [];
levelShapes[lod].push(shapeId);
}
});
@ -436,12 +438,14 @@ export abstract class BaseEdge {
): DisplayObject {
const { keyShape } = shapeMap;
const { haloShape: haloShapeStyle } = this.mergedStyles;
if (haloShapeStyle.visible === false) return;
const { nodeName, attributes } = keyShape;
return this.upsertShape(
nodeName as SHAPE_TYPE,
'haloShape',
{
...attributes,
stroke: attributes.stroke,
...haloShapeStyle,
},
shapeMap,
@ -461,25 +465,38 @@ export abstract class BaseEdge {
this.balanceShapeSize(shapeMap, zoom);
// zoomLevel changed
if (!this.zoomStrategy) return;
const { levels } = this.zoomStrategy;
if (!this.lodStrategy) return;
const { levels } = this.lodStrategy;
const {
levelShapes,
hiddenShape,
animateConfig,
firstRender = true,
zoomLevel: previousLevel,
} = this.zoomCache;
// last zoom ratio responsed by zoom changing, which might not equal to zoom.previous in props since the function is debounced.
const currentLevel = getZoomLevel(levels, zoom);
const levelNums = Object.keys(levelShapes).map(Number);
const maxLevel = Math.max(...levelNums);
const minLevel = Math.min(...levelNums);
if (currentLevel < previousLevel) {
// zoomLevel changed, from higher to lower, hide something
levelShapes[currentLevel + 1]?.forEach((id) =>
if (firstRender) {
for (let i = currentLevel + 1; i <= maxLevel; i++) {
levelShapes[String(i)]?.forEach((id) => shapeMap[id]?.hide());
}
} else {
for (let i = currentLevel + 1; i <= maxLevel; i++) {
levelShapes[String(i)]?.forEach((id) =>
fadeOut(id, shapeMap[id], hiddenShape, animateConfig),
);
}
}
} else if (currentLevel > previousLevel) {
// zoomLevel changed, from lower to higher, show something
levelShapes[String(currentLevel)]?.forEach((id) =>
for (let i = currentLevel; i >= minLevel; i--) {
levelShapes[String(i)]?.forEach((id) =>
fadeIn(
id,
shapeMap[id],
@ -490,9 +507,11 @@ export abstract class BaseEdge {
),
);
}
}
this.zoomCache.zoom = zoom;
this.zoomCache.zoomLevel = currentLevel;
this.zoomCache.firstRender = false;
};
/**
@ -521,7 +540,7 @@ export abstract class BaseEdge {
if (!labelBackgroundShape) return;
const oriBgTransform = this.boundsCache.labelBackgroundShapeTransform;
labelBackgroundShape.style.transform = `${oriBgTransform} scale(1, ${balanceRatio})`;
labelBackgroundShape.style.transform = `${oriBgTransform} scale(${balanceRatio}, ${balanceRatio})`;
}
/**

View File

@ -8,7 +8,7 @@ import {
SHAPE_TYPE_3D,
ShapeStyle,
State,
ZoomStrategyObj,
lodStrategyObj,
} from '../../../types/item';
import {
NodeModelData,
@ -18,6 +18,7 @@ import {
import {
LOCAL_BOUNDS_DIRTY_FLAG_KEY,
formatPadding,
getShapeLocalBoundsByStyle,
mergeStyles,
upsertShape,
} from '../../../util/shape';
@ -31,17 +32,15 @@ export abstract class BaseNode {
defaultStyles: NodeShapeStyles;
themeStyles: NodeShapeStyles;
mergedStyles: NodeShapeStyles;
zoomStrategy?: ZoomStrategyObj;
lodStrategy?: lodStrategyObj;
boundsCache: {
keyShapeLocal?: AABB;
labelShapeLocal?: AABB;
};
// cache the zoom level infomations
private zoomCache: {
protected zoomCache: {
// the id of shapes which are hidden by zoom changing.
hiddenShape: { [shapeId: string]: boolean };
// timeout timer for scaling shapes with to keep the visual size, simulates debounce in function.
balanceTimer: NodeJS.Timeout;
// the ratio to scale the shapes (e.g. labelShape, labelBackgroundShape) to keep to visual size while zooming.
balanceRatio: number;
// last responsed zoom ratio.
@ -56,25 +55,29 @@ export abstract class BaseNode {
wordWrapWidth: number;
// animate configurations for zoom level changing
animateConfig: AnimateCfg;
// the tag of first rendering
firstRender: boolean;
} = {
hiddenShape: {},
zoom: 1,
zoomLevel: 0,
balanceTimer: undefined,
balanceRatio: 1,
levelShapes: {},
wordWrapWidth: 32,
animateConfig: DEFAULT_ANIMATE_CFG.zoom,
firstRender: true,
};
constructor(props) {
const { themeStyles, zoomStrategy } = props;
const { themeStyles, lodStrategy, zoom } = props;
if (themeStyles) this.themeStyles = themeStyles;
this.zoomStrategy = zoomStrategy;
this.lodStrategy = lodStrategy;
this.boundsCache = {};
this.zoomCache.zoom = zoom;
this.zoomCache.balanceRatio = 1 / zoom;
this.zoomCache.animateConfig = {
...DEFAULT_ANIMATE_CFG.zoom,
...zoomStrategy?.animateCfg,
...lodStrategy?.animateCfg,
};
}
public mergeStyles(model: NodeDisplayModel) {
@ -131,25 +134,30 @@ export abstract class BaseNode {
* Call it after calling draw function to update cache about bounds and zoom levels.
*/
public updateCache(shapeMap) {
['keyShape', 'labelShape', 'rightBadgeShape']
.concat(Object.keys(BadgePosition).map((pos) => `${pos}BadgeShape`))
['keyShape', 'labelShape']
.concat(Object.keys(BadgePosition))
.map((pos) => `${pos}BadgeShape`)
.forEach((id) => {
const shape = shapeMap[id];
if (shape?.getAttribute(LOCAL_BOUNDS_DIRTY_FLAG_KEY)) {
this.boundsCache[`${id}Local`] = shape.getLocalBounds();
this.boundsCache[`${id}Local`] =
id === 'labelShape'
? shape.getGeometryBounds()
: shape.getLocalBounds();
shape.setAttribute(LOCAL_BOUNDS_DIRTY_FLAG_KEY, false);
}
});
const { levelShapes } = this.zoomCache;
Object.keys(shapeMap).forEach((shapeId) => {
const { showLevel } = shapeMap[shapeId].attributes;
if (showLevel !== undefined) {
levelShapes[showLevel] = levelShapes[showLevel] || [];
levelShapes[showLevel].push(shapeId);
const { lod } = shapeMap[shapeId].attributes;
if (lod !== undefined) {
levelShapes[lod] = levelShapes[lod] || [];
levelShapes[lod].push(shapeId);
}
});
if (shapeMap.labelShape && this.boundsCache.keyShapeLocal) {
const { maxWidth = '200%' } = this.mergedStyles.labelShape || {};
this.zoomCache.wordWrapWidth = getWordWrapWidthByBox(
this.boundsCache.keyShapeLocal,
@ -157,6 +165,7 @@ export abstract class BaseNode {
1,
);
}
}
abstract draw(
model: NodeDisplayModel,
shapeMap: { [shapeId: string]: DisplayObject },
@ -199,18 +208,23 @@ export abstract class BaseNode {
const { keyShape } = shapeMap;
this.boundsCache.keyShapeLocal =
this.boundsCache.keyShapeLocal || keyShape.getLocalBounds();
const keyShapeBox = this.boundsCache.keyShapeLocal;
const keyShapeBox = getShapeLocalBoundsByStyle(
keyShape,
this.mergedStyles.keyShape,
this.boundsCache.keyShapeLocal,
);
const { labelShape: shapeStyle } = this.mergedStyles;
const {
position,
offsetX: propsOffsetX,
offsetY: propsOffsetY,
offsetZ: propsOffsetZ,
maxWidth,
...otherStyle
} = shapeStyle;
const wordWrapWidth = getWordWrapWidthByBox(
keyShapeBox,
keyShapeBox as AABB,
maxWidth,
this.zoomCache.zoom,
);
@ -218,10 +232,12 @@ export abstract class BaseNode {
const positionPreset = {
x: keyShapeBox.center[0],
y: keyShapeBox.max[1],
z: keyShapeBox.center[2],
textBaseline: 'top',
textAlign: 'center',
offsetX: 0,
offsetY: 0,
offsetZ: 0,
wordWrapWidth,
};
switch (position) {
@ -257,8 +273,12 @@ export abstract class BaseNode {
const offsetY = (
propsOffsetY === undefined ? positionPreset.offsetY : propsOffsetY
) as number;
const offsetZ = (
propsOffsetZ === undefined ? positionPreset.offsetZ : propsOffsetZ
) as number;
positionPreset.x += offsetX;
positionPreset.y += offsetY;
positionPreset.z += offsetZ;
const style: any = {
...this.defaultStyles.labelShape,
@ -280,24 +300,33 @@ export abstract class BaseNode {
!this.boundsCache.labelShapeLocal ||
labelShape.getAttribute(LOCAL_BOUNDS_DIRTY_FLAG_KEY)
) {
this.boundsCache.labelShapeLocal = labelShape.getLocalBounds();
this.boundsCache.labelShapeLocal = labelShape.getGeometryBounds();
labelShape.setAttribute(LOCAL_BOUNDS_DIRTY_FLAG_KEY, false);
}
// label's local bounds, will take scale into acount
const { labelShapeLocal: textBBox } = this.boundsCache;
const labelWidth = Math.min(
textBBox.max[0] - textBBox.min[0],
labelShape.attributes.wordWrapWidth,
);
const height = textBBox.max[1] - textBBox.min[1];
const labelAspectRatio = labelWidth / (textBBox.max[1] - textBBox.min[1]);
const width = labelAspectRatio * height;
const { padding, ...backgroundStyle } =
this.mergedStyles.labelBackgroundShape;
const { balanceRatio = 1 } = this.zoomCache;
const y =
labelShape.attributes.y -
(labelShape.attributes.textBaseline === 'top'
? padding[0]
: height / 2 + padding[0]);
const bgStyle: any = {
fill: '#fff',
...backgroundStyle,
x: textBBox.min[0] - padding[3],
y: textBBox.min[1] - padding[0] / balanceRatio,
width: textBBox.max[0] - textBBox.min[0] + padding[1] + padding[3],
height:
textBBox.max[1] -
textBBox.min[1] +
(padding[0] + padding[2]) / balanceRatio,
x: textBBox.center[0] - width / 2 - padding[3],
y,
width: width + padding[1] + padding[3],
height: height + padding[0] + padding[2],
};
return this.upsertShape(
@ -357,12 +386,14 @@ export abstract class BaseNode {
): DisplayObject {
const { keyShape } = shapeMap;
const { haloShape: haloShapeStyle } = this.mergedStyles;
if (haloShapeStyle.visible === false) return;
const { nodeName, attributes } = keyShape;
return this.upsertShape(
nodeName as SHAPE_TYPE,
'haloShape',
{
...attributes,
stroke: attributes.fill,
...haloShapeStyle,
},
shapeMap,
@ -420,14 +451,19 @@ export abstract class BaseNode {
): {
[shapeId: string]: DisplayObject;
} {
const commonStyle = this.mergedStyles.badgeShapes;
const { badgeShapes: commonStyle, keyShape: keyShapeStyle } =
this.mergedStyles;
const individualConfigs = Object.values(this.mergedStyles).filter(
(style) => style.tag === 'badgeShape',
);
if (!individualConfigs.length) return {};
this.boundsCache.keyShapeLocal =
this.boundsCache.keyShapeLocal || shapeMap.keyShape.getLocalBounds();
const { keyShapeLocal: keyShapeBBox } = this.boundsCache;
const keyShapeBBox = getShapeLocalBoundsByStyle(
shapeMap.keyShape,
keyShapeStyle,
this.boundsCache.keyShapeLocal,
);
const keyShapeWidth = keyShapeBBox.max[0] - keyShapeBBox.min[0];
const shapes = {};
individualConfigs.forEach((config) => {
@ -498,7 +534,7 @@ export abstract class BaseNode {
{
text,
fill: textColor,
fontSize: bgHeight - 2,
fontSize: bgHeight - 3,
x: pos.x,
y: pos.y,
...otherStyles,
@ -509,8 +545,7 @@ export abstract class BaseNode {
shapeMap,
model,
);
this.boundsCache[`${id}Local`] =
this.boundsCache[`${id}Local`] || shapes[id].getLocalBounds();
this.boundsCache[`${id}Local`] = shapes[id].getLocalBounds();
const bbox = this.boundsCache[`${id}Local`];
const bgShapeId = `${position}BadgeBackgroundShape`;
@ -526,7 +561,7 @@ export abstract class BaseNode {
fill: color,
height: bgHeight,
width: bgWidth,
x: bbox.min[0] - 2, // begin at the border, minus half height
x: bbox.min[0] - 3, // begin at the border, minus half height
y: bbox.min[1],
radius: bgHeight / 2,
zIndex,
@ -557,26 +592,43 @@ export abstract class BaseNode {
* @param zoom
*/
public onZoom = (shapeMap: NodeShapeMap, zoom: number) => {
this.balanceShapeSize(shapeMap, zoom);
// zoomLevel changed
if (!this.zoomStrategy) return;
const { levels } = this.zoomStrategy;
if (this.lodStrategy) {
const { levels } = this.lodStrategy;
// last zoom ratio responsed by zoom changing, which might not equal to zoom.previous in props since the function is debounced.
const {
levelShapes,
hiddenShape,
animateConfig,
firstRender = true,
zoomLevel: previousLevel,
} = this.zoomCache;
const currentLevel = getZoomLevel(levels, zoom);
const levelNums = Object.keys(levelShapes).map(Number);
const maxLevel = Math.max(...levelNums);
const minLevel = Math.min(...levelNums);
if (currentLevel < previousLevel) {
if (firstRender) {
// zoomLevel changed, from higher to lower, hide something
levelShapes[currentLevel + 1]?.forEach((id) =>
for (let i = currentLevel + 1; i <= maxLevel; i++) {
levelShapes[String(i)]?.forEach((id) => {
if (!shapeMap[id]) return;
shapeMap[id].hide();
hiddenShape[id] = true;
});
}
} else {
// zoomLevel changed, from higher to lower, hide something
for (let i = currentLevel + 1; i <= maxLevel; i++) {
levelShapes[String(i)]?.forEach((id) =>
fadeOut(id, shapeMap[id], hiddenShape, animateConfig),
);
}
}
} else if (currentLevel > previousLevel) {
// zoomLevel changed, from lower to higher, show something
levelShapes[String(currentLevel)]?.forEach((id) =>
for (let i = currentLevel; i >= minLevel; i--) {
levelShapes[String(i)]?.forEach((id) => {
fadeIn(
id,
shapeMap[id],
@ -584,10 +636,13 @@ export abstract class BaseNode {
this.mergedStyles[id.replace('Background', '')],
hiddenShape,
animateConfig,
),
);
});
}
}
this.zoomCache.zoomLevel = currentLevel;
}
this.balanceShapeSize(shapeMap, zoom);
this.zoomCache.zoom = zoom;
};
@ -604,7 +659,7 @@ export abstract class BaseNode {
this.zoomCache.balanceRatio = balanceRatio;
const { labelShape: labelStyle } = this.mergedStyles;
const { position = 'bottom' } = labelStyle;
if (!labelShape) return;
if (!labelShape || !labelShape.isVisible()) return;
if (position === 'bottom') labelShape.style.transformOrigin = '0';
else labelShape.style.transformOrigin = '';
@ -612,7 +667,7 @@ export abstract class BaseNode {
const wordWrapWidth = this.zoomCache.wordWrapWidth * zoom;
labelShape.style.wordWrapWidth = wordWrapWidth;
if (!labelBackgroundShape) return;
if (!labelBackgroundShape || !labelBackgroundShape.isVisible()) return;
const { padding } = this.mergedStyles.labelBackgroundShape;
const { width, height } = labelBackgroundShape.attributes;
@ -641,8 +696,16 @@ export abstract class BaseNode {
paddingLeft + (width - paddingLeft - paddingRight) / 2
} ${paddingTop + (height - paddingTop - paddingBottom) / 2}`;
}
// only scale y-asix, to expand the text range while zoom-in
labelBackgroundShape.style.transform = `scale(1, ${balanceRatio})`;
const labelBBox = labelShape.getGeometryBounds();
const labelWidth = Math.min(
labelBBox.max[0] - labelBBox.min[0],
this.zoomCache.wordWrapWidth,
);
const xAxistRatio =
((labelWidth + paddingLeft + paddingRight) * balanceRatio) / width;
labelBackgroundShape.style.transform = `scale(${xAxistRatio}, ${balanceRatio})`;
}
public upsertShape(

View File

@ -1,9 +1,7 @@
import { DisplayObject } from '@antv/g';
import { DEFAULT_LABEL_BG_PADDING } from '../../../constant';
import { NodeDisplayModel } from '../../../types';
import {
GShapeStyle,
ItemShapeStyles,
SHAPE_TYPE,
SHAPE_TYPE_3D,
ShapeStyle,
@ -19,7 +17,7 @@ import { BaseNode } from './base';
export abstract class BaseNode3D extends BaseNode {
type: string;
defaultStyles: ItemShapeStyles;
defaultStyles: NodeShapeStyles;
themeStyles: NodeShapeStyles;
mergedStyles: NodeShapeStyles;
device: any; // for 3d renderer
@ -35,7 +33,78 @@ export abstract class BaseNode3D extends BaseNode {
diffData?: { previous: NodeModelData; current: NodeModelData },
diffState?: { previous: State[]; current: State[] },
): DisplayObject {
return super.drawLabelShape(model, shapeMap, diffData, diffState);
const { keyShape } = shapeMap;
const { r, width, height, depth, x, y, z } = keyShape.attributes;
const keyShapeBox = {
center: [x, y, z],
min: [x - r || width / 2, y - r || height / 2, z - r || depth / 2],
max: [x + r || width / 2, y + r || height / 2, z + r || depth / 2],
};
const { labelShape: shapeStyle } = this.mergedStyles;
const {
position,
offsetX: propsOffsetX,
offsetY: propsOffsetY,
maxWidth,
...otherStyle
} = shapeStyle;
// TODO
// const wordWrapWidth = getWordWrapWidthByBox(
// keyShapeBox,
// maxWidth,
// this.zoomCache.zoom,
// );
const positionPreset = {
x: keyShapeBox.center[0],
y: keyShapeBox.max[1],
z: keyShapeBox.center[2],
textBaseline: 'top',
textAlign: 'center',
offsetX: 0,
offsetY: 0,
// wordWrapWidth,
};
switch (position) {
case 'top':
positionPreset.y = keyShapeBox.min[1];
positionPreset.textBaseline = 'bottom';
positionPreset.offsetY = -4;
break;
case 'left':
positionPreset.x = keyShapeBox.min[0];
positionPreset.y = keyShapeBox.center[1];
positionPreset.textAlign = 'right';
positionPreset.textBaseline = 'middle';
positionPreset.offsetX = -8;
break;
case 'right':
positionPreset.x = keyShapeBox.max[0];
positionPreset.y = keyShapeBox.center[1];
positionPreset.textAlign = 'left';
positionPreset.textBaseline = 'middle';
positionPreset.offsetX = 8;
break;
default: // at bottom by default
positionPreset.offsetY = 4;
break;
}
const offsetX = (
propsOffsetX === undefined ? positionPreset.offsetX : propsOffsetX
) as number;
const offsetY = (
propsOffsetY === undefined ? positionPreset.offsetY : propsOffsetY
) as number;
positionPreset.x += offsetX;
positionPreset.y += offsetY;
const style: any = {
...this.defaultStyles.labelShape,
...positionPreset,
...otherStyle,
};
return this.upsertShape('text', 'labelShape', style, shapeMap);
}
// TODO: 3d icon? - billboard image or text for alpha
@ -57,12 +126,14 @@ export abstract class BaseNode3D extends BaseNode {
): DisplayObject {
const { keyShape } = shapeMap;
const { haloShape: haloShapeStyle } = this.mergedStyles;
if (haloShapeStyle.visible === false) return;
const { nodeName, attributes } = keyShape;
return this.upsertShape(
nodeName as SHAPE_TYPE,
'haloShape',
{
...attributes,
stroke: attributes.fill,
...haloShapeStyle,
},
shapeMap,

View File

@ -173,7 +173,12 @@ export default class Minimap extends Base {
}
// Translate tht graph and update minimap viewport.
graph!.translate((dx * zoom) / ratio, (dy * zoom) / ratio).then(() => {
graph!
.translate({
dx: (dx * zoom) / ratio,
dy: (dy * zoom) / ratio,
})
.then(() => {
this.updateViewport();
});
x = e.clientX;

View File

@ -1,100 +1,188 @@
import { DEFAULT_SHAPE_STYLE, DEFAULT_TEXT_STYLE } from '../../constant';
import { ThemeSpecification } from '../../types/theme';
const subjectColor = 'rgb(95, 149, 255)';
const textColor = 'rgb(0, 0, 0)';
const subjectColor = 'rgb(34,126,255)';
const textColor = 'rgba(255,255,255,0.85)';
const activeFill = 'rgb(247, 250, 255)';
// const nodeMainFill = 'rgb(239, 244, 255)';
const nodeMainFill = 'rgb(255, 255, 255)';
const nodeColor = 'rgb(34,126,255)';
const edgeColor = 'rgb(153, 173, 209)';
const comboFill = 'rgb(253, 253, 253)';
const disabledFill = 'rgb(250, 250, 250)';
const disabledFill = '#D0E4FF';
const edgeMainStroke = 'rgb(224, 224, 224)';
const edgeInactiveStroke = 'rgb(234, 234, 234)';
const edgeDisablesStroke = 'rgb(245, 245, 245)';
const inactiveStroke = 'rgb(191, 213, 255)';
const edgeMainStroke = '#637088';
const highlightStroke = '#4572d9';
const highlightFill = 'rgb(223, 234, 255)';
const nodeStroke = '#D0E4FF';
export default {
node: {
palette: [],
palette: [
'#227EFF',
'#AD5CFF',
'#00B8B8',
'#FA822D',
'#F252AF',
'#1EB8F5',
'#108A44',
'#F4B106',
'#5241A8',
'#95CF21',
],
lodStrategy: {
levels: [
{ zoomRange: [0, 0.65] },
{ zoomRange: [0.65, 0.8] },
{ zoomRange: [0.8, 1.6], primary: true },
{ zoomRange: [1.6, 2] },
{ zoomRange: [2, Infinity] },
],
animateCfg: {
duration: 200,
},
},
styles: [
{
default: {
keyShape: {
...DEFAULT_SHAPE_STYLE,
r: 10,
fill: nodeMainFill,
stroke: subjectColor,
lineWidth: 1,
r: 16,
fill: nodeColor,
lineWidth: 0,
zIndex: 0,
},
labelShape: {
...DEFAULT_TEXT_STYLE,
fill: '#000',
fill: textColor,
opacity: 0.85,
position: 'bottom',
offsetY: 4,
zIndex: 2,
lod: 0,
maxWidth: '200%',
textOverflow: 'ellipsis',
wordWrap: true,
maxLines: 1,
},
labelBackgroundShape: {
padding: [2, 4, 2, 4],
lineWidth: 0,
fill: '#000',
opacity: 0.75,
zIndex: -1,
lod: 0,
},
iconShape: {
...DEFAULT_TEXT_STYLE,
fill: '#333',
img: 'https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*wAmHQJbNVdwAAAAAAAAAAABkARQnAQ',
width: 15,
height: 15,
fill: '#fff',
fontSize: 16,
zIndex: 1,
lod: -1,
},
anchorShapes: {
lineWidth: 1,
stroke: nodeStroke,
fill: '#000',
zIndex: 2,
r: 3,
lod: 0,
},
badgeShapes: {
color: 'rgb(140, 140, 140)',
textColor: '#fff',
zIndex: 3,
lod: -1,
},
haloShape: {
visible: false,
},
},
selected: {
keyShape: {
fill: nodeMainFill,
stroke: subjectColor,
lineWidth: 4,
shadowColor: subjectColor,
shadowBlur: 10,
stroke: nodeStroke,
lineWidth: 3,
},
labelShape: {
fontWeight: 500,
fontWeight: 700,
},
haloShape: {
opacity: 0.45,
lineWidth: 20,
zIndex: -1,
visible: true,
},
},
active: {
keyShape: {
fill: activeFill,
stroke: subjectColor,
shadowColor: subjectColor,
lineWidth: 2,
shadowBlur: 10,
lineWidth: 0,
},
haloShape: {
opacity: 0.25,
lineWidth: 20,
zIndex: -1,
visible: true,
},
},
highlight: {
keyShape: {
fill: highlightFill,
stroke: highlightStroke,
lineWidth: 2,
stroke: nodeStroke,
lineWidth: 3,
},
labelShape: {
fontWeight: 500,
fontWeight: 700,
},
haloShape: {
visible: false,
},
},
inactive: {
keyShape: {
fill: activeFill,
stroke: inactiveStroke,
lineWidth: 1,
opacity: 0.45,
},
labelShape: {
opacity: 0.45,
},
iconShape: {
opacity: 0.45,
},
haloShape: {
visible: false,
},
},
disable: {
keyShape: {
fill: disabledFill,
stroke: edgeMainStroke,
lineWidth: 1,
lineWidth: 0,
},
haloShape: {
visible: false,
},
},
},
],
},
edge: {
palette: [],
palette: [
'#63A4FF',
'#CD9CFF',
'#2DEFEF',
'#FFBDA1',
'#F49FD0',
'#80DBFF',
'#41CB7C',
'#FFD362',
'#A192E8',
'#CEFB75',
],
lodStrategy: {
levels: [
{ zoomRange: [0, 0.65] },
{ zoomRange: [0.65, 0.8] },
{ zoomRange: [0.8, 1.6], primary: true },
{ zoomRange: [1.6, 2] },
{ zoomRange: [2, Infinity] },
],
animateCfg: {
duration: 200,
},
},
styles: [
{
default: {
@ -107,52 +195,77 @@ export default {
labelShape: {
...DEFAULT_TEXT_STYLE,
fill: textColor,
textAlign: 'center',
opacity: 0.85,
position: 'middle',
textBaseline: 'middle',
zIndex: 2,
textOverflow: 'ellipsis',
wordWrap: true,
maxLines: 1,
maxWidth: '60%',
lod: 0,
},
labelBackgroundShape: {
padding: [4, 4, 4, 4],
lineWidth: 0,
fill: '#000',
opacity: 0.75,
zIndex: 1,
lod: 0,
},
iconShape: {
...DEFAULT_TEXT_STYLE,
fill: '#333',
img: 'https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*wAmHQJbNVdwAAAAAAAAAAABkARQnAQ',
width: 15,
height: 15,
fill: 'rgb(140, 140, 140)',
fontSize: 16,
zIndex: 2,
offsetX: -10,
lod: -1,
},
},
selected: {
keyShape: {
stroke: subjectColor,
lineWidth: 2,
shadowColor: subjectColor,
shadowBlur: 10,
},
labelShape: {
fontWeight: 500,
fontWeight: 700,
},
haloShape: {
opacity: 0.25,
lineWidth: 12,
zIndex: -1,
visible: true,
},
},
active: {
keyShape: {
stroke: subjectColor,
lineWidth: 1,
},
haloShape: {
opacity: 0.25,
lineWidth: 12,
zIndex: -1,
visible: true,
},
},
highlight: {
keyShape: {
stroke: subjectColor,
lineWidth: 2,
},
labelShape: {
fontWeight: 500,
fontWeight: 700,
},
},
inactive: {
keyShape: {
stroke: edgeInactiveStroke,
stroke: edgeMainStroke,
lineWidth: 1,
opacity: 0.45,
},
},
disable: {
keyShape: {
stroke: edgeDisablesStroke,
stroke: edgeMainStroke,
opacity: 0.08,
lineWidth: 1,
},
},
@ -177,31 +290,28 @@ export default {
},
selected: {
keyShape: {
stroke: subjectColor,
stroke: nodeStroke,
fill: comboFill,
shadowColor: subjectColor,
lineWidth: 2,
shadowBlur: 10,
},
labelShape: {
fontWeight: 500,
fontWeight: 700,
},
},
active: {
keyShape: {
stroke: subjectColor,
stroke: nodeStroke,
lineWidth: 1,
fill: activeFill,
},
},
highlight: {
keyShape: {
stroke: highlightStroke,
stroke: nodeStroke,
fill: comboFill,
lineWidth: 2,
},
labelShape: {
fontWeight: 500,
fontWeight: 700,
},
},
inactive: {
@ -213,9 +323,12 @@ export default {
},
disable: {
keyShape: {
stroke: edgeInactiveStroke,
fill: disabledFill,
lineWidth: 1,
opacity: 0.25,
},
iconShape: {
fill: disabledFill,
opacity: 0.25,
},
},
},

View File

@ -14,7 +14,6 @@ const edgeDisableStroke = 'rgb(217, 217, 217)';
const edgeInactiveStroke = 'rgb(210, 218, 233)';
const nodeStroke = 'rgba(0,0,0,0.85)';
const haloStroke = 'rgb(0, 0, 0)';
export default {
node: {
@ -30,13 +29,13 @@ export default {
'#5241A8',
'#95CF21',
],
zoomStrategy: {
lodStrategy: {
levels: [
{ range: [0, 0.65] },
{ range: [0.65, 0.8] },
{ range: [0.8, 1.6], primary: true },
{ range: [1.6, 2] },
{ range: [2, Infinity] },
{ zoomRange: [0, 0.65] },
{ zoomRange: [0.65, 0.8] },
{ zoomRange: [0.8, 1.6], primary: true },
{ zoomRange: [1.6, 2] },
{ zoomRange: [2, Infinity] },
],
animateCfg: {
duration: 200,
@ -57,7 +56,7 @@ export default {
fill: '#000',
position: 'bottom',
zIndex: 2,
showLevel: 0,
lod: 0,
maxWidth: '200%',
textOverflow: 'ellipsis',
wordWrap: true,
@ -69,53 +68,56 @@ export default {
fill: '#fff',
opacity: 0.75,
zIndex: -1,
showLevel: 0,
lod: 0,
},
iconShape: {
...DEFAULT_TEXT_STYLE,
fill: '#fff',
fontSize: 16,
zIndex: 1,
showLevel: -1,
lod: -1,
},
anchorShapes: {
lineWidth: 1,
stroke: 'rgba(0, 0, 0, 0.65)',
zIndex: 2,
r: 3,
showLevel: 0,
lod: 0,
},
badgeShapes: {
color: 'rgb(140, 140, 140)',
textColor: '#fff',
zIndex: 3,
showLevel: -1,
lod: -1,
},
haloShape: {
visible: false,
},
},
selected: {
keyShape: {
stroke: nodeStroke,
stroke: '#000',
lineWidth: 3,
},
labelShape: {
fontWeight: 500,
fontWeight: 700,
},
haloShape: {
stroke: haloStroke,
opacity: 0.06,
opacity: 0.25,
lineWidth: 20,
zIndex: -1,
visible: true,
},
},
active: {
keyShape: {
stroke: nodeStroke,
lineWidth: 2,
lineWidth: 0,
},
haloShape: {
stroke: haloStroke,
opacity: 0.06,
lineWidth: 4,
opacity: 0.25,
lineWidth: 20,
zIndex: -1,
visible: true,
},
},
highlight: {
@ -124,7 +126,10 @@ export default {
lineWidth: 3,
},
labelShape: {
fontWeight: 500,
fontWeight: 700,
},
haloShape: {
visible: false,
},
},
inactive: {
@ -137,12 +142,18 @@ export default {
iconShape: {
opacity: 0.25,
},
haloShape: {
visible: false,
},
},
disable: {
keyShape: {
fill: disabledFill,
lineWidth: 0,
},
haloShape: {
visible: false,
},
},
},
],
@ -160,13 +171,13 @@ export default {
'#A192E8',
'#CEFB75',
],
zoomStrategy: {
lodStrategy: {
levels: [
{ range: [0, 0.65] },
{ range: [0.65, 0.8] },
{ range: [0.8, 1.6], primary: true },
{ range: [1.6, 2] },
{ range: [2, Infinity] },
{ zoomRange: [0, 0.65] },
{ zoomRange: [0.65, 0.8] },
{ zoomRange: [0.8, 1.6], primary: true },
{ zoomRange: [1.6, 2] },
{ zoomRange: [2, Infinity] },
],
animateCfg: {
duration: 200,
@ -191,7 +202,7 @@ export default {
wordWrap: true,
maxLines: 1,
maxWidth: '60%',
showLevel: 0,
lod: 0,
},
labelBackgroundShape: {
padding: [4, 4, 4, 4],
@ -199,7 +210,7 @@ export default {
fill: '#fff',
opacity: 0.75,
zIndex: 1,
showLevel: 0,
lod: 0,
},
iconShape: {
...DEFAULT_TEXT_STYLE,
@ -207,7 +218,7 @@ export default {
fontSize: 16,
zIndex: 2,
offsetX: -10,
showLevel: -1,
lod: -1,
},
},
selected: {
@ -215,13 +226,13 @@ export default {
lineWidth: 2,
},
labelShape: {
fontWeight: 500,
fontWeight: 700,
},
haloShape: {
stroke: haloStroke,
opacity: 0.06,
opacity: 0.25,
lineWidth: 12,
zIndex: -1,
visible: true,
},
},
active: {
@ -229,10 +240,10 @@ export default {
lineWidth: 1,
},
haloShape: {
stroke: haloStroke,
opacity: 0.06,
opacity: 0.25,
lineWidth: 12,
zIndex: -1,
visible: true,
},
},
highlight: {
@ -240,7 +251,7 @@ export default {
lineWidth: 2,
},
labelShape: {
fontWeight: 500,
fontWeight: 700,
},
},
inactive: {
@ -281,7 +292,7 @@ export default {
lineWidth: 2,
},
labelShape: {
fontWeight: 500,
fontWeight: 700,
},
},
active: {
@ -297,7 +308,7 @@ export default {
lineWidth: 2,
},
labelShape: {
fontWeight: 500,
fontWeight: 700,
},
},
inactive: {
@ -317,6 +328,6 @@ export default {
],
},
canvas: {
backgroundColor: '#000',
backgroundColor: '#fff',
},
} as ThemeSpecification;

View File

@ -9,6 +9,7 @@ export default abstract class BaseThemeSolver {
protected options: ThemeSolverOptions;
constructor(options: ThemeSolverOptions, themes: ThemeSpecificationMap) {
this.specification = this.solver(options, themes);
this.options = options;
}
abstract solver(
options: ThemeSolverOptions,

View File

@ -46,7 +46,7 @@ export default class SpecThemeSolver extends BaseThemeSolver {
if (!specification[itemType]) return;
const {
palette = mergedSpec[itemType].palette,
zoomStrategy = mergedSpec[itemType].zoomStrategy,
lodStrategy = mergedSpec[itemType].lodStrategy,
dataTypeField,
} = specification[itemType];
let { getStyleSets } = specification[itemType];
@ -96,7 +96,7 @@ export default class SpecThemeSolver extends BaseThemeSolver {
mergedSpec[itemType] = {
dataTypeField,
palette,
zoomStrategy,
lodStrategy,
styles: mergedStyles,
};
});

View File

@ -66,7 +66,6 @@ export interface IAnimates {
update?: (IAnimate | IStateAnimate)[];
}
export type CameraAnimationOptions = Pick<
IAnimationEffectTiming,
'duration' | 'easing' | 'easingFunction'
export type CameraAnimationOptions = Partial<
Pick<IAnimationEffectTiming, 'duration' | 'easing' | 'easingFunction'>
>;

View File

@ -39,16 +39,10 @@ export type BehaviorOptionsOf<B extends BehaviorRegistry = {}> =
| Extract<keyof B, string>
| {
[K in keyof B]: B[K] extends { new (options: infer O): any }
? O & { type: K; key: string }
: never;
? { type: K; key: string } & O
: { type: K; key: string };
}[Extract<keyof B, string>];
export type BehaviorObjectOptionsOf<B extends BehaviorRegistry = {}> = {
[K in keyof B]: B[K] extends { new (options: infer O): any }
? O & { type: K; key: string }
: never;
}[Extract<keyof B, string>];
/**
* TODO: interaction specification
*/

View File

@ -10,6 +10,7 @@ import {
ShapeAttrEncode,
ShapesEncode,
ShapeStyle,
LodStrategy,
} from './item';
export type ComboLabelPosition =
@ -46,6 +47,7 @@ export interface ComboShapeStyles extends ItemShapeStyles {
position?: ComboLabelPosition;
offsetX?: number;
offsetY?: number;
offsetZ?: number;
};
labelBackgroundShape?: ShapeStyle & {
padding?: number | number[];
@ -68,6 +70,7 @@ export interface ComboShapeStyles extends ItemShapeStyles {
size?: number;
offsetX?: number;
offsetY?: number;
offsetZ?: number;
// individual styles and their position
[key: number]: ShapeStyle & {
position?: BadgePosition;
@ -76,12 +79,14 @@ export interface ComboShapeStyles extends ItemShapeStyles {
size?: number;
offsetX?: number;
offsetY?: number;
offsetZ?: number;
};
};
}
/** Displayed data, only for drawing and not received by users. */
export type ComboDisplayModelData = ComboModelData & ComboShapeStyles;
export type ComboDisplayModelData = ComboModelData &
ComboShapeStyles & { lodStrategy?: LodStrategy };
/** User input model. */
export type ComboUserModel = GNode<ComboUserModelData>;
@ -97,6 +102,7 @@ interface ComboLabelShapeAttrEncode extends ShapeAttrEncode {
position?: ComboLabelPosition | Encode<ComboLabelPosition>;
offsetX?: number | Encode<number>;
offsetY?: number | Encode<number>;
offsetZ?: number | Encode<number>;
background?: LabelBackground | Encode<LabelBackground>;
}
export interface ComboShapesEncode extends ShapesEncode {

View File

@ -10,6 +10,7 @@ import {
ShapeAttrEncode,
ShapesEncode,
ShapeStyle,
LodStrategy,
} from './item';
export interface EdgeUserModelData extends PlainObject {
@ -61,6 +62,7 @@ export interface EdgeShapeStyles extends ItemShapeStyles {
position?: 'start' | 'middle' | 'end';
offsetX?: number;
offsetY?: number;
offsetZ?: number;
autoRotate?: boolean;
// if it is a string, means the percentage of the keyShape, number means pixel
maxWidth?: string | number;
@ -74,7 +76,8 @@ export interface EdgeShapeStyles extends ItemShapeStyles {
};
}
export type EdgeDisplayModelData = EdgeModelData & EdgeShapeStyles;
export type EdgeDisplayModelData = EdgeModelData &
EdgeShapeStyles & { lodStrategy?: LodStrategy };
/** User input data. */
export type EdgeUserModel = GEdge<EdgeUserModelData>;
@ -88,8 +91,9 @@ export type EdgeDisplayModel = GEdge<EdgeDisplayModelData>;
export type EdgeLabelPosition = 'start' | 'middle' | 'end';
interface EdgeLabelShapeAttrEncode extends ShapeAttrEncode {
position?: EdgeLabelPosition | Encode<EdgeLabelPosition>;
offsetX: number | Encode<number>;
offsetX?: number | Encode<number>;
offsetY?: number | Encode<number>;
offsetZ?: number | Encode<number>;
background?: LabelBackground | Encode<LabelBackground>;
autoRotate?: boolean | Encode<boolean>;
}

View File

@ -3,11 +3,7 @@ import { AABB, Canvas, DisplayObject, PointLike } from '@antv/g';
import { ID } from '@antv/graphlib';
import { Hooks } from '../types/hook';
import { CameraAnimationOptions } from './animate';
import {
BehaviorObjectOptionsOf,
BehaviorOptionsOf,
BehaviorRegistry,
} from './behavior';
import { BehaviorOptionsOf, BehaviorRegistry } from './behavior';
import { ComboModel, ComboUserModel } from './combo';
import { Padding, Point } from './common';
import { GraphData } from './data';
@ -17,7 +13,7 @@ import { LayoutOptions } from './layout';
import { NodeModel, NodeUserModel } from './node';
import { RendererName } from './render';
import { Specification } from './spec';
import { ThemeRegistry } from './theme';
import { ThemeOptionsOf, ThemeRegistry } from './theme';
import { FitViewRules, GraphTransformOptions } from './view';
export interface IGraph<
@ -37,16 +33,26 @@ export interface IGraph<
* @returns
* @group Graph Instance
*/
destroy: () => void;
destroy: (callback?: Function) => void;
/**
* Update the specs(configurations).
* Update the specs (configurations).
*/
updateSpecification: (spec: Specification<B, T>) => void;
updateSpecification: (spec: Specification<B, T>) => Specification<B, T>;
/**
* Update the theme specs (configurations).
*/
updateTheme: (theme: ThemeOptionsOf<T>) => void;
/**
* Get the copy of specs(configurations).
* @returns graph specs
*/
getSpecification: () => Specification<B, T>;
/**
* Change the renderer at runtime.
* @param type renderer name
* @returns
*/
changeRenderer: (type: RendererName) => void;
// ====== data operations ====
/**
@ -230,8 +236,11 @@ export interface IGraph<
* @param effectTiming animation configurations
*/
translate: (
dx: number,
dy: number,
distance: Partial<{
dx: number;
dy: number;
dz: number;
}>,
effectTiming?: CameraAnimationOptions,
) => Promise<void>;
/**
@ -513,7 +522,7 @@ export interface IGraph<
* @returns
* @group Interaction
*/
updateBehavior: (behavior: BehaviorObjectOptionsOf<B>, mode?: string) => void;
updateBehavior: (behavior: BehaviorOptionsOf<B>, mode?: string) => void;
/**
* Draw or update a G shape or group to the transient canvas.

View File

@ -21,7 +21,7 @@ export interface IHook<T> {
export type ViewportChangeHookParams = {
transform: GraphTransformOptions;
effectTiming?: CameraAnimationOptions;
effectTiming?: Partial<CameraAnimationOptions>;
};
export interface Hooks {
@ -85,6 +85,14 @@ export interface Hooks {
| { key: string; type: string; [cfgName: string]: unknown }
)[];
}>;
themechange: IHook<{
theme?: ThemeSpecification;
canvases?: {
background: Canvas;
main: Canvas;
transient: Canvas;
};
}>;
destroy: IHook<{}>;
// TODO: more timecycles here
}

View File

@ -61,7 +61,8 @@ export type GShapeStyle = CircleStyleProps &
export type ShapeStyle = Partial<
GShapeStyle & {
animates?: IAnimates;
showLevel?: number;
lod?: number;
visible?: boolean;
}
>;
export interface Encode<T> {
@ -153,7 +154,7 @@ export type ItemShapeStyles = {
ImageStyleProps & {
offsetX?: number;
offsetY?: number;
showLevel?: number;
lod?: number;
}
>;
haloShape?: ShapeStyle;
@ -164,15 +165,15 @@ export type ItemShapeStyles = {
animates?: IAnimates;
};
export interface ZoomStrategy {
export interface LodStrategy {
levels: {
range: [number, number];
primary: boolean;
zoomRange: [number, number];
primary?: boolean;
}[];
animateCfg: AnimateCfg;
}
export interface ZoomStrategyObj {
export interface lodStrategyObj {
levels: {
[levelIdx: number]: [number, number];
};
@ -221,8 +222,8 @@ export interface IItem {
default?: ItemShapeStyles;
[stateName: string]: ItemShapeStyles;
};
/** The zoom strategy to show and hide shapes according to their showLevel. */
zoomStrategy: ZoomStrategyObj;
/** The zoom strategy to show and hide shapes according to their lod. */
lodStrategy: lodStrategyObj;
/** Last zoom ratio. */
zoom: number;
/** Cache the chaging states which are not consomed by draw */

View File

@ -10,6 +10,7 @@ import {
ShapeAttrEncode,
ShapesEncode,
ShapeStyle,
LodStrategy,
} from './item';
export type NodeLabelPosition = 'bottom' | 'center' | 'top' | 'left' | 'right';
@ -78,6 +79,7 @@ export interface NodeShapeStyles extends ItemShapeStyles {
position?: 'top' | 'bottom' | 'left' | 'right' | 'center';
offsetX?: number;
offsetY?: number;
offsetZ?: number;
// string means the percentage of the keyShape, number means pixel
maxWidth?: string | number;
};
@ -102,6 +104,7 @@ export interface NodeShapeStyles extends ItemShapeStyles {
size?: number;
offsetX?: number;
offsetY?: number;
offsetZ?: number;
// individual styles and their position
[key: number]: ShapeStyle & {
position?: BadgePosition;
@ -110,12 +113,14 @@ export interface NodeShapeStyles extends ItemShapeStyles {
size?: number;
offsetX?: number;
offsetY?: number;
offsetZ?: number;
};
};
}
/** Data in display model. */
export type NodeDisplayModelData = NodeModelData & NodeShapeStyles;
export type NodeDisplayModelData = NodeModelData &
NodeShapeStyles & { lodStrategy?: LodStrategy };
/** User input model. */
export type NodeUserModel = GNode<NodeUserModelData>;
@ -131,6 +136,7 @@ interface NodeLabelShapeAttrEncode extends ShapeAttrEncode {
position?: NodeLabelPosition | Encode<NodeLabelPosition>;
offsetX?: number | Encode<number>;
offsetY?: number | Encode<number>;
offsetZ?: number | Encode<number>;
}
export interface NodeShapesEncode extends ShapesEncode {
labelShape?: NodeLabelShapeAttrEncode | Encode<ShapeStyle>;

View File

@ -1,4 +1,5 @@
import { BehaviorRegistry } from './behavior';
import { ThemeRegistry } from './theme';
export type StdLibCategory =
| 'transform'
@ -12,12 +13,13 @@ export type StdLibCategory =
| 'plugin';
export interface Lib {
transforms?: Record<string, unknown>;
behaviors?: BehaviorRegistry;
themes?: ThemeRegistry;
// TODO: type templates
transforms?: Record<string, unknown>;
layouts?: Record<string, unknown>;
nodes?: Record<string, unknown>;
edges?: Record<string, unknown>;
combos?: Record<string, unknown>;
themes?: Record<string, unknown>;
plugins?: Record<string, unknown>;
}

View File

@ -1,6 +1,7 @@
import BaseThemeSolver from 'stdlib/themeSolver/base';
import { ComboShapeStyles } from './combo';
import { EdgeShapeStyles } from './edge';
import { ZoomStrategy } from './item';
import { LodStrategy } from './item';
import { NodeShapeStyles } from './node';
export interface ThemeOption {}
@ -9,21 +10,21 @@ export interface ThemeOption {}
* Two implementing ways: getSpec or getEvents
*/
export abstract class Theme {
protected options: ThemeOption = {};
constructor(options: ThemeOption) {
options: any = {};
constructor(options: any) {
this.options = options;
}
public updateConfig = (options: ThemeOption) => {
updateConfig = (options: any) => {
this.options = Object.assign(this.options, options);
};
abstract destroy(): void;
destroy() {}
}
/** Theme regisry table.
* @example { 'drag-node': DragNodeBehavior, 'my-drag-node': MyDragNodeBehavior }
*/
export interface ThemeRegistry {
[type: string]: typeof Theme;
[type: string]: typeof BaseThemeSolver;
}
/**
@ -34,8 +35,8 @@ export type ThemeOptionsOf<T extends ThemeRegistry = {}> =
| Extract<keyof T, string>
| {
[K in keyof T]: T[K] extends { new (options: infer O): any }
? O & { type: K; key: string }
: never;
? O & { type: K }
: { type: K };
}[Extract<keyof T, string>];
export type ThemeObjectOptionsOf<T extends ThemeRegistry = {}> = {
@ -73,19 +74,19 @@ export interface NodeThemeSpecifications {
dataTypeField?: string;
palette?: string[] | { [dataTypeValue: string]: string };
styles?: NodeStyleSets;
zoomStrategy?: ZoomStrategy;
lodStrategy?: LodStrategy;
}
export interface EdgeThemeSpecifications {
dataTypeField?: string;
palette?: string[] | { [dataTypeValue: string]: string };
styles?: EdgeStyleSets;
zoomStrategy?: ZoomStrategy;
lodStrategy?: LodStrategy;
}
export interface ComboThemeSpecifications {
dataTypeField?: string;
palette?: string[] | { [dataTypeValue: string]: string };
styles?: ComboStyleSets;
zoomStrategy?: ZoomStrategy;
lodStrategy?: LodStrategy;
}
/**
* Theme specification

View File

@ -13,10 +13,11 @@ export type GraphAlignment =
| [number, number];
export type GraphTransformOptions = {
translate?: {
translate?: Partial<{
dx: number;
dy: number;
};
dz: number;
}>;
rotate?: {
angle: number;
};

View File

@ -275,6 +275,7 @@ const runAnimateOnShape = (
beginStyle: ShapeStyle,
animateConfig,
) => {
if (!shape.isVisible()) return;
let animateArr;
if (!fields?.length) {
animateArr = getStyleDiff(shape.attributes, targetStyle);
@ -284,7 +285,8 @@ const runAnimateOnShape = (
animateArr[0][key] = shape.attributes.hasOwnProperty(key)
? shape.style[key]
: beginStyle[key];
animateArr[1][key] = targetStyle[key];
animateArr[1][key] =
targetStyle[key] === undefined ? animateArr[0][key] : targetStyle[key];
if (key === 'lineDash' && animateArr[1][key].includes('100%')) {
const totalLength = (shape as Line | Polyline | Path).getTotalLength();
replaceElements(animateArr[1][key], '100%', totalLength);
@ -329,7 +331,7 @@ export const animateShapes = (
let i = 0;
const groupKeys = Object.keys(timingAnimateGroups);
if (!groupKeys.length) return;
let animations = [];
const animations = [];
let canceled = false;
const onfinish = () => {
if (i >= groupKeys.length) {
@ -348,11 +350,8 @@ export const animateShapes = (
).filter(Boolean);
groupAnimations.forEach((animation) => {
animation.onframe = onAnimatesFrame;
animations.push(animation);
});
if (i === 0) {
// collect the first group animations
animations = groupAnimations;
}
i++;
};
onfinish();
@ -408,7 +407,12 @@ export const getAnimatesExcludePosition = (animates) => {
export const fadeIn = (id, shape, style, hiddenShape, animateConfig) => {
// omit inexist shape and the shape which is not hidden by zoom changing
if (!shape || !hiddenShape[id]) return;
if (!shape?.isVisible()) {
shape.style.opacity = 0;
shape.show();
}
const { opacity: oriOpacity = 1 } = shape.attributes;
if (oriOpacity === 1) return;
const { opacity = 1 } = style;
shape.animate([{ opacity: 0 }, { opacity }], animateConfig);
};
@ -421,3 +425,13 @@ export const fadeOut = (id, shape, hiddenShape, animateConfig) => {
const animation = shape.animate([{ opacity }, { opacity: 0 }], animateConfig);
animation.onfinish = () => shape.hide();
};
/**
* Make the animation to the end frame and clear it from the target shape.
* @param animation
*/
export const stopAnimate = (animation) => {
const timing = animation.effect.getTiming();
animation.currentTime = Number(timing.duration) + Number(timing.delay || 0);
animation.cancel();
};

View File

@ -23,7 +23,7 @@ export const createCanvas = (
pixelRatio?: number,
customCanvasTag = true,
style: any = {},
) => {
): Canvas => {
let renderer;
switch (rendererType.toLowerCase()) {
case 'svg':
@ -65,3 +65,31 @@ export const createCanvas = (
renderer,
});
};
/**
* Change renderer type for the canvas.
* @param rendererType renderer type
* @param canvas Canvas instance
* @returns
*/
export const changeRenderer = (
rendererType: RendererName,
canvas: Canvas,
): Canvas => {
let renderer;
switch (rendererType.toLowerCase()) {
case 'svg':
renderer = new SVGRenderer();
break;
case 'webgl-3d':
case 'webgl':
renderer = new WebGLRenderer();
renderer.registerPlugin(new Plugin3D());
break;
default:
renderer = new CanvasRenderer();
break;
}
canvas.setRenderer(renderer);
return canvas;
};

View File

@ -14,15 +14,17 @@ import registry from '../stdlib';
export const extend = <
B1 extends BehaviorRegistry,
B2 extends BehaviorRegistry,
T extends ThemeRegistry = ThemeRegistry,
T1 extends ThemeRegistry,
T2 extends ThemeRegistry,
>(
GraphClass: typeof Graph<B2, T>,
GraphClass: typeof Graph<B2, T2>,
extendLibrary: {
behaviors?: B1;
themeSolvers?: T1;
nodes?: any; // TODO
edges?: any; // TODO
},
): typeof Graph<B1 & B2, T> => {
): typeof Graph<B1 & B2, T1 & T2> => {
// merged the extendLibrary to useLib for global usage
Object.keys(extendLibrary).forEach((cat) => {
registry.useLib[cat] = Object.assign(

View File

@ -1,4 +1,4 @@
import { isFunction } from 'util';
import { isFunction } from '@antv/util';
import { StdLibCategory } from '../types/stdlib';
/**

View File

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

View File

@ -14,7 +14,7 @@ import {
AABB,
} from '@antv/g';
import { clone, isArray, isNumber } from '@antv/util';
import { DEFAULT_LABEL_BG_PADDING } from '../constant';
import { DEFAULT_LABEL_BG_PADDING, RESERVED_SHAPE_IDS } from '../constant';
import { Point } from '../types/common';
import { EdgeDisplayModel, EdgeShapeMap } from '../types/edge';
import {
@ -29,6 +29,7 @@ import { ComboDisplayModel } from '../types';
import { getShapeAnimateBeginStyles } from './animate';
import { isArrayOverlap } from './array';
import { isBetween } from './math';
import { getZoomLevel } from './zoom';
export const ShapeTagMap = {
circle: Circle,
@ -129,7 +130,9 @@ export const upsertShape = (
const updateStyles = {};
const oldStyles = shape.attributes;
// update
if (disableAnimate || !animates?.update) {
// update the styles excludes the ones in the animate fields
const animateFields = findAnimateFields(animates, 'update', id);
if (disableAnimate || !animates?.update || !animateFields.length) {
// update all the style directly when there are no animates for update timing
Object.keys(style).forEach((key) => {
if (oldStyles[key] !== style[key]) {
@ -138,9 +141,6 @@ export const upsertShape = (
}
});
} else {
// update the styles excludes the ones in the animate fields
const animateFields = findAnimateFields(animates, 'update', id);
if (!animateFields.length) return shape;
Object.keys(style).forEach((key) => {
if (oldStyles[key] !== style[key]) {
updateStyles[key] = style[key];
@ -292,7 +292,7 @@ const merge2Styles = (
export const isPolygonsIntersect = (
points1: number[][],
points2: number[][],
): boolean => {
): Boolean => {
const getBBox = (points): Partial<AABB> => {
const xArr = points.map((p) => p[0]);
const yArr = points.map((p) => p[1]);
@ -532,3 +532,78 @@ export const isStyleAffectBBox = (
) => {
return isArrayOverlap(Object.keys(style), FEILDS_AFFECT_BBOX[type]);
};
/**
* Estimate the width of the shape according to the given style.
* @param shape target shape
* @param style computed merged style
* @param bounds shape's local bounds
* @returns
*/
export const getShapeLocalBoundsByStyle = (
shape: DisplayObject,
style: ShapeStyle,
bbox?: AABB,
): {
min: number[];
max: number[];
center: number[];
} => {
const {
r,
rx,
ry,
width,
height,
depth = 0,
x1,
x2,
y1,
y2,
z1 = 0,
z2 = 0,
} = style;
const radius = Number(r);
const radiusX = Number(rx);
const radiusY = Number(ry);
switch (shape.nodeName) {
case 'circle':
return {
min: [-radius, -radius, 0],
max: [radius, radius, 0],
center: [0, 0, 0],
};
case 'sphere':
return {
min: [-radius, -radius, -radius],
max: [radius, radius, radius],
center: [0, 0, 0],
};
case 'image':
case 'rect':
case 'cube':
case 'plane':
return {
min: [-width / 2, -height / 2, -depth / 2],
max: [width / 2, height / 2, depth / 2],
center: [0, 0, 0],
};
case 'ellipse':
return {
min: [-radiusX, -radiusY, 0],
max: [radiusX, radiusY, 0],
center: [0, 0, 0],
};
case 'line':
return {
min: [x1, y1, z1],
max: [x2, y2, z2],
center: [(x1 + x2) / 2, (y1 + y2) / 2, (z1 + z2) / 2],
};
case 'text':
case 'polyline':
case 'path':
case 'polygon':
return bbox || shape.getLocalBounds();
}
};

View File

@ -92,6 +92,9 @@ export const createShape3D = (
id,
});
// set origin for shape
shape.setOrigin(0, 0, 0);
// Scale the shape to the correct size.
switch (type) {
case 'cube':

View File

@ -15,8 +15,7 @@ export const getWordWrapWidthByBox = (
zoom = 1,
) => {
const keyShapeWidth = (keyShapeBox.max[0] - keyShapeBox.min[0]) * zoom;
const wordWrapWidth = 2 * keyShapeWidth;
return getWordWrapWidthWithBase(wordWrapWidth, maxWidth);
return getWordWrapWidthWithBase(keyShapeWidth, maxWidth);
};
/**

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,765 @@
import { initThreads, supportsThreads, ForceLayout } from '@antv/layout-wasm';
// import G6, { Graph, GraphData } from '../../../esm';
import G6, { Graph, GraphData } from '../../../src';
import { container, height, width } from '../../datasets/const';
import data from './data';
import data3d from './data3d';
import { labelPropagation } from '@antv/algorithm';
import { RendererName } from '../../../src/types/render';
import { Point } from '../../../src/types/common';
// import Stats from 'stats.js';
let graph: typeof Graph;
let degrees = {};
let dataFor2D: GraphData = { nodes: [], edges: [] };
let dataFor3D: GraphData = { nodes: [], edges: [] };
let colorSelects = [];
const { nodes, edges } = data;
export { nodes, edges, degrees };
const getDefaultNodeAnimates = (delay?: number) => ({
buildIn: [
{
fields: ['opacity'],
duration: 1000,
delay: delay === undefined ? 1000 + Math.random() * 1000 : delay,
},
],
buildOut: [
{
fields: ['opacity'],
duration: 200,
},
],
update: [
{
fields: ['fill', 'r', 'lineWidth'],
shapeId: 'keyShape',
duration: 500,
},
{
fields: ['fontSize'],
shapeId: 'iconShape',
},
{
fields: ['opacity'],
shapeId: 'haloShape',
},
],
hide: [
{
fields: ['size'],
duration: 200,
},
{
fields: ['opacity'],
duration: 200,
shapeId: 'keyShape',
},
{
fields: ['opacity'],
duration: 200,
shapeId: 'labelShape',
},
],
show: [
{
fields: ['size'],
duration: 200,
},
{
fields: ['opacity'],
duration: 200,
shapeId: 'keyShape',
order: 0,
},
],
});
const getDefaultEdgeAnimates = (delay?: number) => ({
buildIn: [
{
fields: ['opacity'],
duration: 300,
delay: delay === undefined ? 1000 + Math.random() * 1000 : delay,
},
],
buildOut: [
{
fields: ['opacity'],
duration: 200,
},
],
update: [
{
fields: ['lineWidth'],
shapeId: 'keyShape',
},
{
fields: ['opacity'],
shapeId: 'haloShape',
},
],
});
const defaultTheme = {
// : ThemeOptionsOf<any>
type: 'spec',
base: 'light',
specification: {
node: {
dataTypeField: 'cluster',
},
},
};
let currentTheme = defaultTheme;
const create2DGraph = (
getNodeAnimates = getDefaultNodeAnimates,
getEdgeAnimates = getDefaultEdgeAnimates,
theme = defaultTheme,
rendererType: RendererName = 'canvas',
) => {
const graph = new Graph({
container: container as HTMLElement,
width,
height: 1400,
type: 'graph',
renderer: rendererType,
data: dataFor2D,
modes: {
default: [
{ type: 'zoom-canvas', key: '123', triggerOnItems: true },
'drag-node',
'drag-canvas',
'hover-activate',
'brush-select',
'click-select',
],
},
theme: { ...defaultTheme, ...theme },
edge: (innerModel) => {
return {
...innerModel,
data: {
...innerModel.data,
type: 'line-edge',
animates: getEdgeAnimates(),
},
};
},
// 节点配置
node: (innerModel) => {
const degree = degrees[innerModel.id] || 0;
let labelLod = 3;
if (degree > 40) labelLod = -2;
else if (degree > 20) labelLod = -1;
else if (degree > 10) labelLod = 0;
else if (degree > 5) labelLod = 1;
else if (degree > 2) labelLod = 2;
return {
...innerModel,
data: {
animates: getNodeAnimates(),
...innerModel.data,
lodStrategy: {
levels: [
{ zoomRange: [0, 0.16] }, // -2
{ zoomRange: [0.16, 0.2] }, // -1
{ zoomRange: [0.2, 0.3], primary: true }, // 0
{ zoomRange: [0.3, 0.5] }, // 1
{ zoomRange: [0.5, 0.8] }, // 2
{ zoomRange: [0.8, 1.5] }, // 3
{ zoomRange: [1.5, 1.8] }, // 4
{ zoomRange: [1.8, 2] }, // 5
{ zoomRange: [2, Infinity] }, // 6
],
animateCfg: {
duration: 500,
},
},
labelShape:
degree !== 0
? {
text: innerModel.data.label,
maxWidth: '400%',
offsetY: 8,
lod: labelLod,
}
: undefined,
labelBackgroundShape:
degree !== 0
? {
lod: labelLod,
}
: undefined,
iconShape:
degree !== 0
? {
img: 'https://gw.alipayobjects.com/zos/basement_prod/012bcf4f-423b-4922-8c24-32a89f8c41ce.svg',
fontSize: 12 + degree / 4,
opacity: 0.8,
lod: labelLod + 2,
}
: undefined,
keyShape: {
r: 12 + degree / 4,
},
},
};
},
});
graph.zoom(0.15);
return graph;
};
const create3DGraph = async () => {
G6.stdLib.layouts['force-wasm'] = ForceLayout;
const supported = await supportsThreads();
const threads = await initThreads(supported);
const newGraph = new Graph({
container: container as HTMLDivElement,
width,
height: 1400,
type: 'graph',
renderer: 'webgl-3d',
data: dataFor3D,
// layout: {
// type: 'force-wasm',
// threads,
// dimensions: 2,
// maxIteration: 5000,
// minMovement: 0.1,
// distanceThresholdMode: 'mean',
// height,
// width,
// center: [width / 2, height / 2],
// factor: 1,
// gravity: 5,
// linkDistance: 200,
// edgeStrength: 200,
// nodeStrength: 1000,
// coulombDisScale: 0.005,
// damping: 0.9,
// maxSpeed: 2000,
// interval: 0.02,
// },
// layout: {
// type: 'force-wasm',
// threads,
// dimensions: 3,
// iterations: 300,
// minMovement: 10,
// height,
// width,
// linkDistance: 200,
// edgeStrength: 100,
// nodeStrength: 2000,
// center: [width / 2, height / 2, 0],
// },
modes: {
default: [
{
type: 'orbit-canvas-3d',
trigger: 'drag',
},
'zoom-canvas-3d',
],
},
theme: {
type: 'spec',
base: 'dark',
specification: {
node: {
dataTypeField: 'cluster',
},
},
},
edge: (innerModel) => {
return {
...innerModel,
data: {
...innerModel.data,
keyShape: {
lineWidth: 0.6,
opacity: 0.6,
stroke: '#fff',
},
type: 'line-edge',
},
};
},
node: (innerModel) => {
return {
...innerModel,
data: {
...innerModel.data,
type: 'sphere-node',
keyShape: {
r: 12 + degrees[innerModel.id] / 2,
},
labelShape:
degrees[innerModel.id] > 20
? {
text: innerModel.data.label,
fontSize: 100,
lod: -1,
fill: 'rgba(255,255,255,0.85)',
wordWrap: false, // FIXME: mesh.getBounds() returns an empty AABB
isBillboard: true,
}
: undefined,
},
};
},
});
const rotate = (camera, rx: number, ry: number, graph) => {
const { width, height } = graph.canvas.getConfig();
const dx = 20.0 / height;
const dy = 20.0 / width;
let motionFactorX = 10;
let motionFactorY = 10;
if (rx * rx > 2 * ry * ry) {
motionFactorY *= 0.5;
} else if (ry * ry > 2 * rx * rx) {
motionFactorX *= 0.5;
}
const rotX = rx * dx * motionFactorX;
const rotY = ry * dy * motionFactorY;
camera.rotate(rotX, 0);
};
let timer;
setTimeout(() => {
const camera = newGraph.canvas.getCamera();
const oripos = camera.getPosition();
let k = 0;
let i = 0;
const tick = () => {
camera.setPosition([oripos[0], oripos[1], oripos[2] + k]);
const rdx =
i < 100 ? Math.min(i * 0.5, 20) : Math.min((200 - i) * 0.2, 20);
rotate(camera, rdx, rdx, newGraph);
timer = requestAnimationFrame(tick);
if (i > 200) cancelAnimationFrame(timer);
const param = i < 50 ? 3 : 0.5;
k += 50 * param;
i++;
};
tick();
}, 1000);
newGraph.once('canvas:pointerdown', (e) => {
if (timer) cancelAnimationFrame(timer);
});
newGraph.once('wheel', (e) => {
if (timer) cancelAnimationFrame(timer);
});
// });
return newGraph;
};
const generateColorSelect = (id, container) => {
const colorSelect = document.createElement('input');
colorSelect.style.width = '25px';
colorSelect.style.height = '25px';
colorSelect.style.border = '0';
colorSelect.style.background = 'rgba(0, 0, 0, 0)';
colorSelect.type = 'color';
colorSelect.id = `color-${id}`;
colorSelect.value = id === 'bg' ? '#ffffff' : '#cccccc';
container.appendChild(colorSelect);
return colorSelect;
};
const addButtons = () => {
const btn = document.createElement('button');
btn.innerHTML = '全屏';
btn.style.position = 'absolute';
btn.style.top = '56px';
btn.style.left = '16px';
btn.style.zIndex = '100';
document.body.appendChild(btn);
btn.addEventListener('click', (e) => {
const canvasEl = graph.canvas.context.config.canvas;
const requestMethod =
canvasEl.requestFullScreen ||
canvasEl.webkitRequestFullScreen ||
canvasEl.mozRequestFullScreen ||
canvasEl.msRequestFullScreen;
if (requestMethod) {
// Native full screen.
requestMethod.call(canvasEl);
} else if (typeof window.ActiveXObject !== 'undefined') {
// Older IE.
const wscript = new ActiveXObject('WScript.Shell');
if (wscript !== null) {
wscript.SendKeys('{F11}');
}
}
});
const btnZoomIn = document.createElement('button');
btnZoomIn.innerHTML = '放大';
btnZoomIn.style.position = 'absolute';
btnZoomIn.style.top = '56px';
btnZoomIn.style.left = '62px';
btnZoomIn.style.width = '48px';
btnZoomIn.style.zIndex = '100';
document.body.appendChild(btnZoomIn);
const btnZoomOut = document.createElement('button');
btnZoomOut.innerHTML = '缩小';
btnZoomOut.style.position = 'absolute';
btnZoomOut.style.top = '56px';
btnZoomOut.style.left = '112px';
btnZoomOut.style.width = '48px';
btnZoomOut.style.zIndex = '100';
document.body.appendChild(btnZoomOut);
const rendererSelect = document.createElement('select');
rendererSelect.style.position = 'absolute';
rendererSelect.style.top = '86px';
rendererSelect.style.left = '16px';
rendererSelect.style.width = '143px';
rendererSelect.style.height = '25px';
rendererSelect.style.zIndex = '100';
const option1 = document.createElement('option');
option1.innerHTML = 'Canvas';
const option2 = document.createElement('option');
option2.innerHTML = 'WebGL';
const option3 = document.createElement('option');
option3.innerHTML = 'WebGL-3D';
const option4 = document.createElement('option');
option4.innerHTML = 'SVG(coming soon)';
option4.disabled = true;
rendererSelect.appendChild(option1);
rendererSelect.appendChild(option2);
rendererSelect.appendChild(option3);
rendererSelect.appendChild(option4);
document.body.appendChild(rendererSelect);
const themeSelect = document.createElement('select');
themeSelect.style.position = 'absolute';
themeSelect.style.top = '116px';
themeSelect.style.left = '16px';
themeSelect.style.width = '143px';
themeSelect.style.height = '25px';
themeSelect.style.zIndex = '100';
const themeOption0 = document.createElement('option');
themeOption0.innerHTML = '亮色主题';
const themeOption1 = document.createElement('option');
themeOption1.innerHTML = '暗色主题';
const themeOption2 = document.createElement('option');
themeOption2.innerHTML = '蓝色主题';
const themeOption3 = document.createElement('option');
themeOption3.innerHTML = '橙色主题';
const themeOption4 = document.createElement('option');
themeOption4.innerHTML = '自定义';
themeSelect.appendChild(themeOption0);
themeSelect.appendChild(themeOption1);
themeSelect.appendChild(themeOption2);
themeSelect.appendChild(themeOption3);
themeSelect.appendChild(themeOption4);
document.body.appendChild(themeSelect);
// 自定义色板
const customThemeSelect = document.createElement('div');
const paletteContainer = document.createElement('div');
paletteContainer.style.display = 'inline-flex';
customThemeSelect.appendChild(paletteContainer);
const addColorBtn = document.createElement('a');
addColorBtn.innerHTML = '+';
addColorBtn.style.margin = '4px';
addColorBtn.style.cursor = 'pointer';
addColorBtn.style.border = '1px dashed rgba(34, 126, 255, 0.5)';
addColorBtn.style.padding = '2px 8px';
addColorBtn.style.color = 'rgb(34, 126, 255)';
paletteContainer.appendChild(addColorBtn);
addColorBtn.addEventListener('click', (e) => {
colorSelects.push(
generateColorSelect(`${colorSelects.length}`, colorsContainer),
);
});
const colorsContainer = document.createElement('div');
colorsContainer.style.display = 'inline-flex';
colorsContainer.style.margin = '4px 0';
paletteContainer.appendChild(colorsContainer);
colorSelects = [generateColorSelect('0', colorsContainer)];
const removeColorBtn = document.createElement('a');
removeColorBtn.innerHTML = '-';
removeColorBtn.style.margin = '4px';
removeColorBtn.style.cursor = 'pointer';
removeColorBtn.style.border = '1px dashed rgba(34, 126, 255, 0.5)';
removeColorBtn.style.padding = '2px 10px';
removeColorBtn.style.color = 'rgb(34, 126, 255)';
paletteContainer.appendChild(removeColorBtn);
removeColorBtn.addEventListener('click', (e) => {
if (colorSelects.length <= 1) return;
const removingSelect = colorSelects.splice(colorSelects.length - 1, 1)[0];
removingSelect.remove();
});
const backgroundColorContainer = document.createElement('div');
backgroundColorContainer.style.margin = '8px 0';
const backgroundLabel = document.createElement('div');
backgroundLabel.innerHTML = '背景色:';
backgroundLabel.style.display = 'inline-flex';
backgroundLabel.style.fontSize = '14px';
backgroundColorContainer.appendChild(backgroundLabel);
const bgColorSelect = generateColorSelect('bg', colorsContainer);
backgroundColorContainer.appendChild(bgColorSelect);
bgColorSelect.style.display = 'inline-flex';
customThemeSelect.appendChild(backgroundColorContainer);
const customConfirmBtn = document.createElement('button');
customConfirmBtn.innerHTML = '应用';
customConfirmBtn.style.cursor = 'pointer';
customConfirmBtn.style.width = '109px';
customConfirmBtn.style.border = '0';
customConfirmBtn.style.backgroundColor = 'rgba(34, 126, 255, 0.5)';
customThemeSelect.appendChild(customConfirmBtn);
customConfirmBtn.addEventListener('click', (e) => {
graph.updateTheme({
type: 'spec',
specification: {
canvas: {
backgroundColor: bgColorSelect.value || '#fff',
},
node: {
dataTypeField: 'cluster',
palette: colorSelects.map((dom) => dom.value),
},
},
});
});
customThemeSelect.style.position = 'absolute';
customThemeSelect.style.display = 'none';
customThemeSelect.style.top = '146px';
customThemeSelect.style.left = '16px';
customThemeSelect.style.zIndex = '100';
customThemeSelect.style.padding = '8px';
customThemeSelect.style.backgroundColor = 'rgb(212, 230, 255)';
document.body.appendChild(customThemeSelect);
return {
rendererSelect,
themeSelect,
customThemeSelect,
zoomIn: btnZoomIn,
zoomOut: btnZoomOut,
};
};
const handleSwitchRenderer = (rendererName, oldgraph) => {
switch (rendererName) {
case 'webgl-3d':
oldgraph.destroy(async () => {
graph = await create3DGraph();
});
break;
case 'canvas':
oldgraph.destroy(() => {
graph = create2DGraph(undefined, undefined, currentTheme);
});
break;
case 'webgl':
oldgraph.destroy(() => {
const currentZoom = oldgraph.getZoom();
const position = oldgraph.canvas.getCamera().getPosition();
const zoomOpt = {
zoom: currentZoom,
center: { x: position[0], y: position[1] },
};
graph = create2DGraph(undefined, undefined, currentTheme, 'webgl');
});
// oldgraph.changeRenderer('webgl');
break;
case 'svg':
// comming soon
default:
break;
}
return graph;
};
const handleSwitchTheme = (themeType, customThemeSelect) => {
customThemeSelect.style.display = 'none';
switch (themeType) {
case '亮色主题':
currentTheme = defaultTheme;
graph.updateTheme(defaultTheme);
return;
case '暗色主题':
currentTheme = {
...defaultTheme,
base: 'dark',
};
graph.updateTheme(currentTheme);
return;
case '蓝色主题':
currentTheme = {
type: 'spec',
base: 'light',
specification: {
canvas: {
backgroundColor: '#f3faff',
},
node: {
dataTypeField: 'cluster',
palette: [
'#bae0ff',
'#91caff',
'#69b1ff',
'#4096ff',
'#1677ff',
'#0958d9',
'#003eb3',
'#002c8c',
'#001d66',
],
},
},
};
graph.updateTheme(currentTheme);
return;
case '橙色主题':
currentTheme = {
type: 'spec',
base: 'light',
specification: {
canvas: {
backgroundColor: '#fcf9f1',
},
node: {
dataTypeField: 'cluster',
palette: [
'#ffe7ba',
'#ffd591',
'#ffc069',
'#ffa940',
'#fa8c16',
'#d46b08',
'#ad4e00',
'#873800',
'#612500',
],
},
},
};
graph.updateTheme(currentTheme);
return;
case '自定义':
customThemeSelect.style.display = 'block';
return;
}
};
const zoomLevels = [0.15, 0.16, 0.2, 0.3, 0.5, 0.8, 1.5, 2];
const handleZoom = (graph, isIn = true) => {
let toZoom = 0.15;
const currentZoom = graph.getZoom();
if (isIn) {
for (let i = 0; i < zoomLevels.length - 1; i++) {
if (currentZoom <= zoomLevels[i]) {
toZoom = zoomLevels[i + 1];
break;
}
}
} else {
for (let i = zoomLevels.length - 1; i >= 1; i--) {
if (currentZoom >= zoomLevels[i]) {
toZoom = zoomLevels[i - 1];
break;
}
}
}
graph.zoomTo(toZoom, { x: 2194, y: -1347 }, { duration: 500 });
};
const getDataFor2D = (inputData) => {
const clusteredData = labelPropagation(inputData, false);
clusteredData.clusters.forEach((cluster, i) => {
cluster.nodes.forEach((node) => {
node.data.cluster = `c${i}`;
});
});
// for 性能测试
// data.nodes.forEach((node) => {
// delete node.data.x;
// delete node.data.y;
// delete node.data.z;
// });
const degrees = {};
inputData.edges.forEach((edge) => {
const { source, target } = edge;
degrees[source] = degrees[source] || 0;
degrees[target] = degrees[target] || 0;
degrees[source]++;
degrees[target]++;
});
inputData.nodes.forEach((node) => delete node.data.z);
return { degrees, data: inputData };
};
const getDataFor3D = (inputData) => {
const clusteredData3D = labelPropagation(inputData, false);
clusteredData3D.clusters.forEach((cluster, i) => {
cluster.nodes.forEach((node) => {
node.data.cluster = `c${i}`;
});
});
// data3d.nodes.forEach((node) => {
// delete node.data.x;
// delete node.data.y;
// delete node.data.z;
// });
return inputData;
};
export default () => {
const result2d = getDataFor2D(data);
degrees = result2d.degrees;
dataFor2D = result2d.data;
dataFor3D = getDataFor3D(data3d);
graph = create2DGraph();
const { rendererSelect, themeSelect, customThemeSelect, zoomIn, zoomOut } =
addButtons();
rendererSelect.addEventListener('change', (e: any) => {
const type = e.target.value;
console.log('changerenderer', graph);
handleSwitchRenderer(type.toLowerCase(), graph);
// graph.changeRenderer(type.toLowerCase());
});
themeSelect.addEventListener('change', (e: any) => {
const type = e.target.value;
handleSwitchTheme(type, customThemeSelect);
});
zoomIn.addEventListener('click', () => handleZoom(graph, true));
zoomOut.addEventListener('click', () => handleZoom(graph, false));
// stats
// const stats = new Stats();
// stats.showPanel(0);
// const $stats = stats.dom;
// $stats.style.position = 'absolute';
// $stats.style.left = '0px';
// $stats.style.top = '0px';
// document.body.appendChild($stats);
// graph.canvas.addEventListener('afterrender', () => {
// if (stats) {
// stats.update();
// }
// });
return graph;
};

View File

@ -0,0 +1,125 @@
import G6 from '@antv/G6';
import { container, width } from '../../datasets/const';
import data from './data';
import { labelPropagation } from '@antv/algorithm';
import Stats from 'stats.js';
const nodeIds = [];
const edges = data.edges.map((edge) => {
return {
id: `edge-${Math.random()}`,
source: edge.source,
target: edge.target,
data: {},
};
});
const degrees = {};
edges.forEach((edge) => {
const { source, target } = edge;
degrees[source] = degrees[source] || 0;
degrees[target] = degrees[target] || 0;
degrees[source]++;
degrees[target]++;
});
const nodes = data.nodes
.map((node) => {
const id = node.id || `node-${Math.random()}`;
if (nodeIds.includes(id)) return;
nodeIds.push(id);
return {
id: id as string,
// label: node.olabel,
x: node.x * 10 - 3000,
y: node.y * 10 - 5000,
icon: {
show: true,
img: 'https://gw.alipayobjects.com/zos/basement_prod/012bcf4f-423b-4922-8c24-32a89f8c41ce.svg',
width: 12 + degrees[id] / 4,
height: 12 + degrees[id] / 4,
opacity: 0.8,
},
};
})
.filter(Boolean);
const clusteredData = labelPropagation({ nodes, edges }, false);
const subjectColors = [
'#5F95FF', // blue
'#61DDAA',
'#65789B',
'#F6BD16',
'#7262FD',
'#78D3F8',
'#9661BC',
'#F6903D',
'#008685',
'#F08BB4',
];
const colorSets = G6.Util.getColorSetsBySubjectColors(
subjectColors,
'#fff',
'default',
'#777',
);
clusteredData.clusters.forEach((cluster, i) => {
const colorSet = colorSets[i % colorSets.length];
cluster.nodes.forEach((node) => {
node.cluster = `c${i}`;
node.style = {
fill: colorSet.mainFill,
stroke: colorSet.mainStroke,
r: 12 + degrees[node.id] / 4,
};
});
});
const create2DGraph = () => {
const graph = new G6.Graph({
container: container,
width,
height: 800,
fitView: true,
minZoom: 0.0001,
modes: {
default: [
{ type: 'zoom-canvas' },
// @ts-ignore
{
type: 'drag-canvas',
enableOptimize: false,
},
'drag-node',
'brush-select',
],
},
});
graph.read({ nodes, edges });
// graph.zoom(0.15);
return graph;
};
export default () => {
const graph = create2DGraph();
// stats
const stats = new Stats();
stats.showPanel(0);
const $stats = stats.dom;
$stats.style.position = 'absolute';
$stats.style.left = '0px';
$stats.style.top = '0px';
document.body.appendChild($stats);
const update = () => {
if (stats) {
stats.update();
}
requestAnimationFrame(update);
};
update();
return graph;
};

View File

@ -10,6 +10,10 @@ import layouts_fruchterman_gpu from './layouts/fruchterman-gpu';
import layouts_force_3d from './layouts/force-3d';
import layouts_force_wasm_3d from './layouts/force-wasm-3d';
import performance from './performance/performance';
import performance_layout from './performance/layout';
import performance_layout_3d from './performance/layout-3d';
import demo from './demo/demo';
import demoFor4 from './demo/demoFor4';
export {
behaviors_activateRelations,
layouts_circular,
@ -23,4 +27,8 @@ export {
behaviors_brush_select,
behaviors_click_select,
performance,
performance_layout,
performance_layout_3d,
demo,
demoFor4,
};

View File

@ -57,18 +57,27 @@ export default async () => {
strokeOpacity: 0.6,
},
},
node: {
node: (innerModel) => {
return {
...innerModel,
data: {
...innerModel.data,
type: 'sphere-node',
keyShape: {
r: 10,
opacity: 0.6,
},
// labelShape: {
// text: 'node-label',
// },
// iconShape: {
// img: 'https://gw.alipayobjects.com/zos/basement_prod/012bcf4f-423b-4922-8c24-32a89f8c41ce.svg',
// },
labelShape: {
text: innerModel.id,
fill: 'white',
maxWidth: '400%',
lod: -1,
offsetY: 20,
wordWrap: false, // FIXME: mesh.getBounds() returns an empty AABB
isBillboard: true,
},
},
};
},
nodeState: {
selected: {

View File

@ -0,0 +1,207 @@
import G6 from '../../../src/index';
import { supportsThreads, initThreads, ForceLayout } from '@antv/layout-wasm';
import { loadDataset } from '../../datasets/legacy-format';
export default async () => {
const $container = document.getElementById('container')!;
$container.style.display = 'none';
const WIDTH = 500;
const HEIGHT = 500;
const $app = document.getElementById('app')!;
const $containers = document.createElement('div');
$app.appendChild($containers);
$containers.style.display = 'flex';
const $container1 = document.createElement('div');
const $container2 = document.createElement('div');
$containers.appendChild($container1);
$containers.appendChild($container2);
$container1.style.flex = '1';
$container1.style.position = 'relative';
$container2.style.flex = '1';
$container2.style.position = 'relative';
const $timer1 = document.createElement('div');
$timer1.style.cssText = `
font-size: 26px;
color: white;
position: absolute;
top: 0;
left: 0;
z-index: 1;`;
$container1.appendChild($timer1);
const $timer2 = document.createElement('div');
$timer2.style.cssText = `
font-size: 26px;
color: white;
position: absolute;
top: 0;
left: 0;
z-index: 1;`;
$container2.appendChild($timer2);
const data = await loadDataset(
'https://gw.alipayobjects.com/os/basement_prod/da5a1b47-37d6-44d7-8d10-f3e046dabf82.json',
);
const layoutOptions = {
dimensions: 3,
maxIteration: 500,
minMovement: 0.4,
distanceThresholdMode: 'mean',
height: HEIGHT,
width: WIDTH,
center: [WIDTH / 2, HEIGHT / 2, 0],
factor: 1,
gravity: 10,
linkDistance: 200,
edgeStrength: 200,
nodeStrength: 1000,
coulombDisScale: 0.005,
damping: 0.9,
maxSpeed: 1000,
interval: 0.02,
};
// Force layout WASM
(async () => {
const supported = await supportsThreads();
const threads = await initThreads(supported);
// Register custom layout
G6.stdLib.layouts['force-wasm'] = ForceLayout;
const graph = new G6.Graph({
container: $container1,
width: WIDTH,
height: HEIGHT,
type: 'graph',
renderer: 'webgl-3d',
modes: {
default: [
{
type: 'orbit-canvas-3d',
trigger: 'drag',
},
'zoom-canvas-3d',
],
},
data: JSON.parse(JSON.stringify(data)),
edge: {
type: 'line-edge',
keyShape: {
lineWidth: 1,
stroke: 'grey',
strokeOpacity: 0.6,
},
},
node: {
type: 'sphere-node',
keyShape: {
r: 10,
opacity: 0.6,
},
},
layout: {
type: 'force-wasm',
threads,
...layoutOptions,
},
});
let timer;
graph.on('startlayout', () => {
const startTime = performance.now();
timer = setInterval(() => {
$timer1.innerHTML = `@antv/layout-wasm: ${(
performance.now() - startTime
).toFixed(2)}ms`;
}, 1);
});
graph.on('endlayout', () => {
clearInterval(timer);
const camera = graph.canvas.getCamera();
let counter = 0;
const tick = () => {
if (counter < 80) {
camera.dolly(3);
counter++;
}
camera.rotate(0.4, 0);
requestAnimationFrame(tick);
};
tick();
});
})();
// Force layout
(() => {
const graph = new G6.Graph({
container: $container2,
width: WIDTH,
height: HEIGHT,
type: 'graph',
renderer: 'webgl-3d',
modes: {
default: [
{
type: 'orbit-canvas-3d',
trigger: 'drag',
},
'zoom-canvas-3d',
],
},
edge: {
type: 'line-edge',
keyShape: {
lineWidth: 1,
stroke: 'grey',
strokeOpacity: 0.6,
},
},
node: {
type: 'sphere-node',
keyShape: {
r: 10,
opacity: 0.6,
},
},
data: JSON.parse(JSON.stringify(data)),
layout: {
type: 'force',
workerEnabled: true,
...layoutOptions,
},
});
let timer;
graph.on('startlayout', () => {
const startTime = performance.now();
timer = setInterval(() => {
$timer2.innerHTML = `@antv/layout: ${(
performance.now() - startTime
).toFixed(2)}ms`;
}, 1);
});
graph.on('endlayout', () => {
clearInterval(timer);
const camera = graph.canvas.getCamera();
let counter = 0;
const tick = () => {
if (counter < 80) {
camera.dolly(3);
counter++;
}
camera.rotate(0.4, 0);
requestAnimationFrame(tick);
};
tick();
});
})();
};

View File

@ -0,0 +1,204 @@
import G6 from '../../../src/index';
import { supportsThreads, initThreads, ForceLayout } from '@antv/layout-wasm';
import { loadDataset } from '../../datasets/legacy-format';
import { labelPropagation } from '@antv/algorithm';
export default async () => {
const $container = document.getElementById('container')!;
$container.style.display = 'none';
const WIDTH = 500;
const HEIGHT = 500;
const $app = document.getElementById('app')!;
const $containers = document.createElement('div');
$app.appendChild($containers);
$containers.style.display = 'flex';
const $container1 = document.createElement('div');
const $container2 = document.createElement('div');
$containers.appendChild($container1);
$containers.appendChild($container2);
$container1.style.flex = '1';
$container1.style.position = 'relative';
$container1.id = 'wasm';
$container2.style.flex = '1';
$container2.style.position = 'relative';
$container2.id = 'cpu';
const $timer1 = document.createElement('div');
$timer1.style.cssText = `
font-size: 26px;
color: white;
position: absolute;
top: 0;
left: 0;
z-index: 1;`;
$container1.appendChild($timer1);
const $timer2 = document.createElement('div');
$timer2.style.cssText = `
font-size: 26px;
color: white;
position: absolute;
top: 0;
left: 0;
z-index: 1;`;
$container2.appendChild($timer2);
const data = await loadDataset(
'https://gw.alipayobjects.com/os/basement_prod/da5a1b47-37d6-44d7-8d10-f3e046dabf82.json',
);
const clusteredData = labelPropagation(data, false);
clusteredData.clusters.forEach((cluster, i) => {
cluster.nodes.forEach((node) => {
node.data.cluster = `c${i}`;
});
});
const degrees = {};
data.edges.forEach((edge) => {
const { source, target } = edge;
degrees[source] = degrees[source] || 0;
degrees[target] = degrees[target] || 0;
degrees[source]++;
degrees[target]++;
});
const configures = {
modes: {
default: ['zoom-canvas', 'drag-node'],
},
theme: {
type: 'spec',
specification: {
node: {
dataTypeField: 'cluster',
},
},
},
node: (innerModel) => {
return {
...innerModel,
data: {
...innerModel.data,
keyShape: {
...innerModel.data.keyShape,
r: 12 + degrees[innerModel.id] / 4,
},
},
};
},
edge: (innerModel) => {
return {
...innerModel,
data: {
...innerModel.data,
type: 'line-edge',
keyShape: {
lineWidth: 1,
stroke: '#fff',
opacity: 1,
},
},
};
},
};
const layoutOptions = {
dimensions: 2,
maxIteration: 200,
minMovement: 0.4,
distanceThresholdMode: 'mean',
height: HEIGHT,
width: WIDTH,
center: [WIDTH / 2, HEIGHT / 2],
factor: 1,
gravity: 10,
linkDistance: 200,
edgeStrength: 200,
nodeStrength: 1000,
coulombDisScale: 0.005,
damping: 0.9,
maxSpeed: 1000,
interval: 0.02,
};
// Force layout WASM
(async () => {
const supported = await supportsThreads();
const threads = await initThreads(supported);
// Register custom layout
G6.stdLib.layouts['force-wasm'] = ForceLayout;
const graph = new G6.Graph({
container: $container1,
width: WIDTH,
height: HEIGHT,
type: 'graph',
data: JSON.parse(JSON.stringify(data)),
layout: {
type: 'force-wasm',
threads,
...layoutOptions,
maxIteration: 400,
minMovement: 0.8,
},
...configures,
});
let timer;
graph.on('startlayout', () => {
const startTime = performance.now();
timer = setInterval(() => {
$timer1.innerHTML = `Time: ${(performance.now() - startTime).toFixed(
2,
)}ms`;
}, 1);
});
graph.on('endlayout', () => {
clearInterval(timer);
graph.zoom(0.1, undefined, {
duration: 1000,
});
});
})();
// Force layout
(() => {
const graph = new G6.Graph({
container: $container2,
width: WIDTH,
height: HEIGHT,
type: 'graph',
data: JSON.parse(JSON.stringify(data)),
layout: {
type: 'force',
workerEnabled: true,
...layoutOptions,
maxIteration: 8000,
minMovement: 0.2,
},
...configures,
});
let timer;
graph.on('startlayout', () => {
const startTime = performance.now();
timer = setInterval(() => {
$timer2.innerHTML = `Time: ${(performance.now() - startTime).toFixed(
2,
)}ms`;
}, 1);
});
graph.on('endlayout', () => {
clearInterval(timer);
graph.zoom(0.1, undefined, {
duration: 1000,
});
});
})();
};

View File

@ -1,10 +1,12 @@
import G6 from '../../../src/index';
import {
BadgePosition,
IBadgePosition,
ShapeStyle,
} from '../../../src/types/item';
import { container, height, width } from '../../datasets/const';
ForceLayout,
FruchtermanLayout,
initThreads,
supportsThreads,
} from '@antv/layout-wasm';
import G6 from '../../../src/index';
import { IBadgePosition } from '../../../src/types/item';
import { container, width } from '../../datasets/const';
const data = {
nodes: [
{ id: 'Myriel', data: { x: 122.619579008568, y: -121.31805520154471 } },
@ -1744,9 +1746,7 @@ const clusters = [
'Brujon',
],
];
let nodes = data.nodes.map((node) => {
node.data.x += 400;
node.data.y += 250;
const nodes = data.nodes.map((node) => {
let nocluster = true;
clusters.forEach((cluster, i) => {
if (cluster.includes(node.id)) {
@ -1759,196 +1759,221 @@ let nodes = data.nodes.map((node) => {
// @ts-ignore
node.data.cluster = 0;
}
node.data.layout = {
id: node.id,
x: node.data.x,
y: node.data.y,
};
return node;
});
nodes = nodes
.concat(
nodes.map((node) => {
return {
...node,
id: `${node.id}-2`,
data: {
...node.data,
x: node.data.x + 500,
},
};
}),
)
.concat(
nodes.map((node) => {
return {
...node,
id: `${node.id}-3`,
data: {
...node.data,
x: node.data.x + 1000,
},
};
}),
)
.concat(
nodes.map((node) => {
return {
...node,
id: `${node.id}-4`,
data: {
...node.data,
x: node.data.x + 1500,
},
};
}),
)
.concat(
nodes.map((node) => {
return {
...node,
id: `${node.id}-5`,
data: {
...node.data,
y: node.data.y + 1000,
},
};
}),
)
.concat(
nodes.map((node) => {
return {
...node,
id: `${node.id}-6`,
data: {
...node.data,
x: node.data.x + 500,
y: node.data.y + 1000,
},
};
}),
)
.concat(
nodes.map((node) => {
return {
...node,
id: `${node.id}-7`,
data: {
...node.data,
x: node.data.x + 1000,
y: node.data.y + 1000,
},
};
}),
)
.concat(
nodes.map((node) => {
return {
...node,
id: `${node.id}-8`,
data: {
...node.data,
x: node.data.x + 1500,
y: node.data.y + 1000,
},
};
}),
);
const edges = data.edges
.concat(
data.edges.map((edge) => {
return {
...edge,
id: `${edge.id}-2`,
source: `${edge.source}-2`,
target: `${edge.target}-2`,
};
}),
)
.concat(
data.edges.map((edge) => {
return {
...edge,
id: `${edge.id}-3`,
source: `${edge.source}-3`,
target: `${edge.target}-3`,
};
}),
)
.concat(
data.edges.map((edge) => {
return {
...edge,
id: `${edge.id}-4`,
source: `${edge.source}-4`,
target: `${edge.target}-4`,
};
}),
)
.concat(
data.edges.map((edge) => {
return {
...edge,
id: `${edge.id}-5`,
source: `${edge.source}-5`,
target: `${edge.target}-5`,
};
}),
)
.concat(
data.edges.map((edge) => {
return {
...edge,
id: `${edge.id}-6`,
source: `${edge.source}-6`,
target: `${edge.target}-6`,
};
}),
)
.concat(
data.edges.map((edge) => {
return {
...edge,
id: `${edge.id}-7`,
source: `${edge.source}-7`,
target: `${edge.target}-7`,
};
}),
)
.concat(
data.edges.map((edge) => {
return {
...edge,
id: `${edge.id}-8`,
source: `${edge.source}-8`,
target: `${edge.target}-8`,
};
}),
);
export default () => {
console.log(
'graphsize: #NODE:',
nodes.length,
', #EDGE:',
edges.length,
'#SHAPES',
nodes.length * 10 + edges.length * 4,
);
return new G6.Graph({
// nodes = nodes.concat(
// nodes.map((node) => {
// return {
// ...node,
// id: `${node.id}-2`,
// data: {
// ...node.data,
// x: node.data.x + 500,
// },
// };
// }),
// );
// .concat(
// nodes.map((node) => {
// return {
// ...node,
// id: `${node.id}-3`,
// data: {
// ...node.data,
// x: node.data.x + 1000,
// },
// };
// }),
// );
// .concat(
// nodes.map((node) => {
// return {
// ...node,
// id: `${node.id}-4`,
// data: {
// ...node.data,
// x: node.data.x + 1500,
// },
// };
// }),
// )
// .concat(
// nodes.map((node) => {
// return {
// ...node,
// id: `${node.id}-5`,
// data: {
// ...node.data,
// y: node.data.y + 1000,
// },
// };
// }),
// )
// .concat(
// nodes.map((node) => {
// return {
// ...node,
// id: `${node.id}-6`,
// data: {
// ...node.data,
// x: node.data.x + 500,
// y: node.data.y + 1000,
// },
// };
// }),
// )
// .concat(
// nodes.map((node) => {
// return {
// ...node,
// id: `${node.id}-7`,
// data: {
// ...node.data,
// x: node.data.x + 1000,
// y: node.data.y + 1000,
// },
// };
// }),
// )
// .concat(
// nodes.map((node) => {
// return {
// ...node,
// id: `${node.id}-8`,
// data: {
// ...node.data,
// x: node.data.x + 1500,
// y: node.data.y + 1000,
// },
// };
// }),
// );
const edges = data.edges;
// const edges = data.edges.concat(
// data.edges.map((edge) => {
// return {
// ...edge,
// id: `${edge.id}-2`,
// source: `${edge.source}-2`,
// target: `${edge.target}-2`,
// };
// }),
// );
// .concat(
// data.edges.map((edge) => {
// return {
// ...edge,
// id: `${edge.id}-3`,
// source: `${edge.source}-3`,
// target: `${edge.target}-3`,
// };
// }),
// )
// .concat(
// data.edges.map((edge) => {
// return {
// ...edge,
// id: `${edge.id}-4`,
// source: `${edge.source}-4`,
// target: `${edge.target}-4`,
// };
// }),
// )
// .concat(
// data.edges.map((edge) => {
// return {
// ...edge,
// id: `${edge.id}-5`,
// source: `${edge.source}-5`,
// target: `${edge.target}-5`,
// };
// }),
// )
// .concat(
// data.edges.map((edge) => {
// return {
// ...edge,
// id: `${edge.id}-6`,
// source: `${edge.source}-6`,
// target: `${edge.target}-6`,
// };
// }),
// )
// .concat(
// data.edges.map((edge) => {
// return {
// ...edge,
// id: `${edge.id}-7`,
// source: `${edge.source}-7`,
// target: `${edge.target}-7`,
// };
// }),
// )
// .concat(
// data.edges.map((edge) => {
// return {
// ...edge,
// id: `${edge.id}-8`,
// source: `${edge.source}-8`,
// target: `${edge.target}-8`,
// };
// }),
// );
const degrees = {};
edges.forEach((edge) => {
const { source, target } = edge;
degrees[source] = degrees[source] || 0;
degrees[target] = degrees[target] || 0;
degrees[source]++;
degrees[target]++;
});
const createGraph = async () => {
const supported = await supportsThreads();
const threads = await initThreads(supported);
G6.stdLib.layouts['force-wasm'] = ForceLayout;
G6.stdLib.layouts['fruchterman-wasm'] = FruchtermanLayout;
const graph = new G6.Graph({
container: container as HTMLElement,
width,
height: 1200,
type: 'graph',
renderer: 'webgl',
// renderer: 'webgl',
data: { nodes, edges },
layout: {
type: 'force-wasm',
threads,
dimensions: 2,
maxIteration: 800,
minMovement: 0.4,
distanceThresholdMode: 'mean',
factor: 1,
gravity: 10,
linkDistance: 80,
edgeStrength: 200,
nodeStrength: 1000,
coulombDisScale: 0.005,
damping: 0.9,
maxSpeed: 1000,
interval: 0.02,
},
modes: {
default: [
'zoom-canvas',
// @ts-ignore
{
type: 'drag-canvas',
scalableRange: 0.9,
},
'drag-canvas',
'drag-node',
'brush-select',
'click-select',
'hover-activate',
],
},
// @ts-ignore
theme: {
type: 'spec',
specification: {
@ -1962,54 +1987,96 @@ export default () => {
...innerModel,
data: {
...innerModel.data,
labelShape: {
position: 'end',
text: 'edge-label',
},
labelBackgroundShape: {
fill: '#fff',
},
iconShape: {
text: 'A',
keyShape: {
lineWidth: 0.5,
},
haloShape: {},
animates: {
buildIn: [
{
fields: ['opacity'],
duration: 300,
shapeId: 'keyShape',
duration: 500,
delay: 1000,
},
],
buildOut: [
{
fields: ['opacity'],
duration: 200,
},
],
update: [
{
fields: ['lineWidth'],
shapeId: 'keyShape',
},
{
fields: ['opacity'], // 'r' error, 'lineWidth' has no effect
shapeId: 'haloShape',
},
],
},
},
};
},
node: (innerModel) => {
const badgeShapes: (ShapeStyle & {
position?: IBadgePosition;
color?: string;
textColor?: string;
})[] = [
{
const degree = degrees[innerModel.id] || 0;
let labelLod = 4;
if (degree > 20) labelLod = -1;
else if (degree > 15) labelLod = 0;
else if (degree > 10) labelLod = 1;
else if (degree > 6) labelLod = 2;
else if (degree > 3) labelLod = 3;
const badgeShapes = {};
if (degree > 20) {
badgeShapes[0] = {
text: '核心人员',
position: 'right',
position: 'right' as IBadgePosition,
color: '#389e0d',
},
{
lod: labelLod - 2,
};
}
if (degree > 15) {
badgeShapes[1] = {
text: 'A',
position: 'rightTop',
position: 'rightTop' as IBadgePosition,
color: '#d4380d',
},
{
lod: labelLod - 1,
};
}
if (degree > 10) {
badgeShapes[2] = {
text: 'B',
position: 'rightBottom',
color: '#ccc',
},
];
position: 'rightBottom' as IBadgePosition,
color: '#aaa',
lod: labelLod - 1,
};
}
return {
...innerModel,
data: {
...innerModel.data,
lodStrategy: {
levels: [
{ zoomRange: [0, 0.8] }, // -2
{ zoomRange: [0.8, 0.9] }, // -1
{ zoomRange: [0.9, 1], primary: true }, // 0
{ zoomRange: [1, 1.1] }, // 1
{ zoomRange: [1.1, 0.2] }, // 2
{ zoomRange: [1.2, 1.3] }, // 3
{ zoomRange: [1.3, 1.4] }, // 4
{ zoomRange: [1.4, 1.5] }, // 5
{ zoomRange: [1.5, Infinity] }, // 6
],
animateCfg: {
duration: 500,
},
},
animates: {
buildIn: [
{
@ -2018,6 +2085,12 @@ export default () => {
delay: 500 + Math.random() * 500,
},
],
buildOut: [
{
fields: ['opacity'],
duration: 200,
},
],
hide: [
{
fields: ['size'],
@ -2046,40 +2119,117 @@ export default () => {
order: 0,
},
],
update: [
{
fields: ['fill', 'r'],
shapeId: 'keyShape',
},
{
fields: ['lineWidth'],
shapeId: 'keyShape',
duration: 100,
},
{
fields: ['fontSize'],
shapeId: 'iconShape',
},
{
fields: ['opacity'], // 'r' error, 'lineWidth' has no effect
shapeId: 'haloShape',
},
],
},
haloShape: {},
// animate in shapes, unrelated to each other, excuted parallely
keyShape: {
r: 15,
r: innerModel.data.size ? innerModel.data.size / 2 : 15,
},
iconShape: {
img: 'https://gw.alipayobjects.com/zos/basement_prod/012bcf4f-423b-4922-8c24-32a89f8c41ce.svg',
fill: '#fff',
lod: labelLod - 1,
fontSize: innerModel.data.size ? innerModel.data.size / 3 + 5 : 13,
},
labelShape: {
text: innerModel.id,
opacity: 0.8,
maxWidth: '150%',
lod: labelLod,
},
labelBackgroundShape: {},
badgeShapes: {
0: {
text: '核心人员',
position: 'right' as IBadgePosition,
color: '#389e0d',
},
1: {
text: 'A',
position: 'rightTop' as IBadgePosition,
color: '#d4380d',
},
2: {
text: 'B',
position: 'rightBottom' as IBadgePosition,
color: '#aaa',
},
labelBackgroundShape: {
lod: labelLod,
},
badgeShapes,
},
};
},
});
graph.zoomTo(0.7);
return graph;
};
export default async () => {
console.log(
'graphsize: #NODE:',
nodes.length,
', #EDGE:',
edges.length,
'#SHAPES',
nodes.length * 10 + edges.length * 4,
);
let graph = await createGraph();
const btnImportance = document.createElement('button');
btnImportance.innerHTML = '节点重要性分析';
btnImportance.style.position = 'absolute';
btnImportance.style.top = '164px';
btnImportance.style.left = '373px';
btnImportance.style.zIndex = '100';
document.body.appendChild(btnImportance);
btnImportance.addEventListener('click', (e) => {
graph.updateData(
'node',
nodes.map((node) => ({
id: node.id,
data: {
size: degrees[node.id] + 24,
},
})),
);
});
const btnColor = document.createElement('button');
btnColor.innerHTML = '更换颜色顺序';
btnColor.style.position = 'absolute';
btnColor.style.top = '164px';
btnColor.style.left = '573px';
btnColor.style.zIndex = '100';
document.body.appendChild(btnColor);
btnColor.addEventListener('click', (e) => {
graph.updateData(
'node',
nodes.map((node) => ({
id: node.id,
data: {
cluster: node.data.cluster + 1,
},
})),
);
});
const btnDestroy = document.createElement('button');
btnDestroy.innerHTML = '销毁图';
btnDestroy.style.position = 'absolute';
btnDestroy.style.top = '164px';
btnDestroy.style.left = '773px';
btnDestroy.style.zIndex = '100';
document.body.appendChild(btnDestroy);
btnDestroy.addEventListener('click', async (e) => {
if (graph.destroyed) {
graph = await createGraph();
} else {
graph.destroy();
}
});
return graph;
};

View File

@ -1,5 +1,6 @@
import * as graphs from './intergration/index';
performance.mark('create select');
const SelectGraph = document.getElementById('select') as HTMLSelectElement;
const Options = Object.keys(graphs).map((key) => {
const option = document.createElement('option');
@ -7,6 +8,7 @@ const Options = Object.keys(graphs).map((key) => {
option.textContent = key;
return option;
});
performance.mark('create select');
SelectGraph.replaceChildren(...Options);
@ -19,8 +21,16 @@ SelectGraph.onchange = (e) => {
graphs[value]();
};
performance.mark('init');
// 初始化
const params = new URL(location.href).searchParams;
const initialExampleName = params.get('name');
SelectGraph.value = initialExampleName || Options[0].value;
graphs[SelectGraph.value]();
performance.mark('init');
console.log(
'create select',
performance.measure('create select'),
performance.measure('init'),
);

View File

@ -50,7 +50,7 @@ describe('plugin', () => {
expect(pxCompare(viewport.style.height, 120)).toBe(true);
graph.zoom(3);
graph.translate(50, 250);
graph.translate({ dx: 50, dy: 250 });
// setTimeout for: 1. zoom an translate are async function; 2. minimap viewport debounce update
setTimeout(() => {
expect(pxCompare(viewport.style.left, 100)).toBe(true);

View File

@ -1925,13 +1925,13 @@ describe('graph show up animations', () => {
...innerModel,
data: {
...innerModel.data,
// zoomStrategy: {
// lodStrategy: {
// levels: [
// { range: [0, 0.65] },
// { range: [0.65, 0.8] },
// { range: [0.8, 1.6], primary: true },
// { range: [1.6, 2] },
// { range: [2, Infinity] },
// { zoomRange: [0, 0.65] },
// { zoomRange: [0.65, 0.8] },
// { zoomRange: [0.8, 1.6], primary: true },
// { zoomRange: [1.6, 2] },
// { zoomRange: [2, Infinity] },
// ],
// animateCfg: {
// duration: 200,
@ -1986,13 +1986,13 @@ describe('graph show up animations', () => {
x,
y,
// TODO: different for nodes, and config in theme
// zoomStrategy: {
// lodStrategy: {
// levels: [
// { range: [0, 0.65] },
// { range: [0.65, 0.8] },
// { range: [0.8, 1.6], primary: true },
// { range: [1.6, 2] },
// { range: [2, Infinity] },
// { zoomRange: [0, 0.65] },
// { zoomRange: [0.65, 0.8] },
// { zoomRange: [0.8, 1.6], primary: true },
// { zoomRange: [1.6, 2] },
// { zoomRange: [2, Infinity] },
// ],
// animateCfg: {
// duration: 200,
@ -2071,13 +2071,13 @@ describe('graph show up animations', () => {
// default: ['zoom-canvas'],
// },
// node: d => {
// zoomStrategy: {
// lodStrategy: {
// levels: [
// { range: [0, 0.65] },
// { range: [0.65, 0.8] },
// { range: [0.8, 1.6], primary: true },
// { range: [1.6, 2] },
// { range: [2, Infinity] },
// { zoomRange: [0, 0.65] },
// { zoomRange: [0.65, 0.8] },
// { zoomRange: [0.8, 1.6], primary: true },
// { zoomRange: [1.6, 2] },
// { zoomRange: [2, Infinity] },
// ],
// animateCfg: {
// duration: 200,

View File

@ -22,12 +22,12 @@ describe('viewport', () => {
});
graph.once('afterlayout', () => {
graph.translate(250, 250);
graph.translate({ dx: 250, dy: 250 });
let [px, py] = graph.canvas.getCamera().getPosition();
expect(px).toBeCloseTo(0, 1);
expect(py).toBeCloseTo(0, 1);
graph.translate(-250, -250);
graph.translate({ dx: -250, dy: -250 });
[px, py] = graph.canvas.getCamera().getPosition();
expect(px).toBeCloseTo(250, 1);
expect(py).toBeCloseTo(250, 1);
@ -52,9 +52,12 @@ describe('viewport', () => {
});
graph.once('afterlayout', async () => {
await graph.translate(249, 249, {
await graph.translate(
{ dx: 249, dy: 249 },
{
duration: 1000,
});
},
);
graph.once('viewportchange', ({ translate }) => {
expect(translate.dx).toBeCloseTo(-250, 1);
@ -429,9 +432,12 @@ describe('viewport', () => {
});
graph.once('afterlayout', async () => {
await graph.translate(249, 249, {
await graph.translate(
{ dx: 249, dy: 249 },
{
duration: 1000,
});
},
);
graph.once('viewportchange', ({ translate }) => {
expect(translate.dx).toBeCloseTo(-249, 1);
@ -447,7 +453,7 @@ describe('viewport', () => {
});
await graph.zoom(0.5);
await graph.translate(249, 249);
await graph.translate({ dx: 249, dy: 249 });
graph.once('viewportchange', ({ translate }) => {
expect(translate.dx).toBeCloseTo(-249, 1);
expect(translate.dy).toBeCloseTo(-249, 1);
@ -577,10 +583,13 @@ describe('viewport', () => {
easing: 'ease-in',
},
);
await graph.translate(100, 100, {
await graph.translate(
{ dx: 100, dy: 100 },
{
duration: 1000,
easing: 'ease-in',
});
},
);
await graph.fitView(
{
padding: [150, 100],

View File

@ -10,7 +10,16 @@ export default defineConfig({
// @see https://github.com/vitejs/vite/issues/10839#issuecomment-1345193175
// @see https://vitejs.dev/guide/dep-pre-bundling.html#customizing-the-behavior
// @see https://vitejs.dev/config/dep-optimization-options.html#optimizedeps-exclude
exclude: ['@antv/layout-wasm'],
exclude: [
'@antv/layout-wasm',
// '@antv/gui',
// '@antv/layout',
// '@antv/algorithm',
// '@antv/layout-gpu',
// '@antv/g',
// '@antv/g-canvas',
// '@antv/g-webgl',
],
},
plugins: [
{